Zwei Teile, ein Treffer — und einer verschwand

Du hast drei Filme vermisst, und die Zahlen sagten schon, wo sie sind: 448
Filme plus 13 zweite Fassungen sind genau die 461 Dateien. Es fehlte keine
Datei; drei Filme steckten als Fassung in einem anderen.

Gemessen, warum. Der Dateinamenleser ist unschuldig — „Teil 2" bleibt im
Titel. TMDB aber liefert für „Heiligtümer des Todes Teil 1" wie für „Teil 2"
denselben Treffer zuerst, sobald ohne Jahr gesucht werden muss. Und weil
zwei Einträge dieselbe Kennung nicht teilen dürfen, wurde der zweite
geschluckt.

Zwei Dateien, deren Namen verschiedene Jahre nennen, sind jetzt verschiedene
Filme. Der erste Anlauf hatte ein Jahr Abstand als Toleranz erlaubt — für
Dezember-Erstaufführungen — und verschluckte damit genau den Fall, um den es
geht: Teil 1 ist von 2010, Teil 2 von 2011.

Dazu sieht man endlich, was nicht zugeordnet werden konnte. „Nicht
zugeordnet" listete bisher nur Dateien ganz ohne Titel, und die gibt es bei
Filmen nie — deshalb standen dort nur Serien. Daneben eine Liste der Titel
mit mehreren Dateien: zwei Fassungen sind die Regel, zwei verschiedene Filme
der Fehler, und an den Dateinamen sieht man es sofort.

Der Bearbeiten-Dialog hat jetzt Reiter statt Knöpfe, die weitere Fenster
öffnen — ein Fenster über einem Fenster, und man wusste nicht mehr, was man
zumacht. „Titel ändern" und „Bilder bearbeiten" sind aus dem Kontextmenü
raus; über „Bearbeiten" kommt man an beides.

Beim Prüfen gefunden: ein Dialog blieb nach einem Seitenwechsel über der
neuen Seite stehen. Er hängt an body, nicht am Inhaltsbereich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 13:21:42 +02:00
co-authored by Claude Opus 5
parent fcf40e4394
commit c3124ba27c
12 changed files with 626 additions and 78 deletions
@@ -23,6 +23,28 @@ public sealed record Speicherort(
/// <summary>Dateien, die zu keinem Titel gehören — mit Begründung.</summary> /// <summary>Dateien, die zu keinem Titel gehören — mit Begründung.</summary>
public sealed record Unzugeordnet(int Gesamt, IReadOnlyList<UnzugeordneteDatei> Dateien); public sealed record Unzugeordnet(int Gesamt, IReadOnlyList<UnzugeordneteDatei> Dateien);
/// <summary>Ein Titel, zu dem die Metadatenquelle nichts hergab.</summary>
///
/// <param name="Art">„movie" oder „series" — damit der Client weiss, wohin.</param>
/// <param name="Dateien">Wie viele Dateien daran hängen.</param>
/// <param name="Beispiel">Ein Dateiname, damit man den Titel wiedererkennt.</param>
///
/// <remarks>
/// <b>Weil die Zahl allein nichts erklärt.</b> In der Übersicht standen 448
/// Filme, in Plex 451 — und nirgends stand, welche drei fehlen oder warum.
/// Eine Mediathek, die etwas nicht zuordnen konnte, muss sagen was.
/// </remarks>
public sealed record OhneZuordnung(
Guid Id, string Art, string Titel, int? Jahr, int Dateien, string? Beispiel);
/// <param name="Titel">Titel mit mehr als einer Datei — dort versteckt sich Falsches.</param>
public sealed record Mehrfachbericht(
int Gesamt, IReadOnlyList<Mehrfachtitel> Titel);
/// <param name="Dateien">Die Namen der Dateien, die an diesem einen Titel hängen.</param>
public sealed record Mehrfachtitel(
Guid Id, string Art, string Titel, int? Jahr, IReadOnlyList<string> Dateien);
public sealed record UnzugeordneteDatei(Guid Id, string Pfad, string Grund); public sealed record UnzugeordneteDatei(Guid Id, string Pfad, string Grund);
/// <summary>Wie viele Titel in welcher Sprache vorliegen.</summary> /// <summary>Wie viele Titel in welcher Sprache vorliegen.</summary>
@@ -227,6 +227,92 @@ public static class ManagementEndpoints
f.Id, f.Path, FileNames.WarumNicht(f.Path, bibliothek.Path))).ToList())); f.Id, f.Path, FileNames.WarumNicht(f.Path, bibliothek.Path))).ToList()));
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
// **Was die Metadatenquelle nicht hergab.**
//
// `/unassigned` listet Dateien ganz ohne Titel — bei einer
// Filmbibliothek ist das immer leer, weil der Scan für jede Datei
// einen Eintrag anlegt. Sichtbar war damit nur die Hälfte: dass ein
// Film zwar dasteht, aber unter seinem Dateinamen und ohne Plakat,
// erfuhr niemand. In der Übersicht standen 448 Filme, in Plex 451 —
// und nirgends, welche drei fehlen.
bibliotheken.MapGet("/{id:guid}/unmatched", async (
Guid id, FlaroContext db, int take = 300) =>
{
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound();
var grenze = Math.Clamp(take, 1, 2000);
var filme = await db.Movies.AsNoTracking()
.Where(m => m.LibraryId == id && m.TmdbId == null)
.OrderBy(m => m.SortTitleValue)
.Take(grenze)
.Select(m => new
{
m.Id, m.Title, Jahr = m.ParsedYear ?? m.Year,
Dateien = db.MediaFiles.Count(f => f.MovieId == m.Id),
Beispiel = db.MediaFiles.Where(f => f.MovieId == m.Id)
.Select(f => f.Path).FirstOrDefault(),
})
.ToListAsync();
var serien = await db.Series.AsNoTracking()
.Where(x => x.LibraryId == id && x.TmdbId == null)
.OrderBy(x => x.SortTitleValue)
.Take(grenze)
.Select(x => new
{
x.Id, x.Title, Jahr = x.ParsedYear ?? x.Year,
Dateien = db.Episodes.Count(f => f.SeriesId == x.Id),
Beispiel = db.Episodes.Where(f => f.SeriesId == x.Id)
.Join(db.MediaFiles, f => f.Id, d => d.EpisodeId, (_, d) => d.Path)
.FirstOrDefault(),
})
.ToListAsync();
var liste = filme
.Select(m => new OhneZuordnung(
m.Id, "movie", m.Title, m.Jahr, m.Dateien, Dateiname(m.Beispiel)))
.Concat(serien.Select(x => new OhneZuordnung(
x.Id, "series", x.Title, x.Jahr, x.Dateien, Dateiname(x.Beispiel))))
.OrderBy(x => x.Titel, StringComparer.CurrentCultureIgnoreCase)
.ToList();
return Results.Ok(liste);
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Titel mit mehr als einer Datei — dort versteckt sich Falsches.**
//
// Zwei Dateien an einem Film sind meist zwei Fassungen und völlig in
// Ordnung. Manchmal sind es aber zwei verschiedene Filme, die bei der
// Metadatenquelle auf denselben Treffer liefen — und dann ist einer
// davon lautlos verschwunden. Wer die Liste durchsieht, erkennt das
// an den Dateinamen in zwei Sekunden.
bibliotheken.MapGet("/{id:guid}/versions", async (
Guid id, FlaroContext db, int take = 300) =>
{
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound();
var mehrfach = await db.Movies.AsNoTracking()
.Where(m => m.LibraryId == id && db.MediaFiles.Count(f => f.MovieId == m.Id) > 1)
.OrderBy(m => m.SortTitleValue)
.Take(Math.Clamp(take, 1, 2000))
.Select(m => new
{
m.Id, m.Title, Jahr = m.ParsedYear ?? m.Year,
Pfade = db.MediaFiles.Where(f => f.MovieId == m.Id)
.Select(f => f.Path).ToList(),
})
.ToListAsync();
var liste = mehrfach
.Select(m => new Mehrfachtitel(
m.Id, "movie", m.Title, m.Jahr,
m.Pfade.Select(p => Dateiname(p) ?? p).ToList()))
.ToList();
return Results.Ok(new Mehrfachbericht(liste.Count, liste));
}).RequireAuthorization(FlaroPolicies.Administrator);
bibliotheken.MapGet("/missing", async (FlaroContext db) => bibliotheken.MapGet("/missing", async (FlaroContext db) =>
{ {
var liste = await db.MediaFiles.AsNoTracking() var liste = await db.MediaFiles.AsNoTracking()
@@ -395,6 +481,10 @@ public static class ManagementEndpoints
return Results.NoContent(); return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
} }
/// <summary>Nur der Dateiname — der volle Pfad geht niemanden etwas an.</summary>
private static string? Dateiname(string? pfad) =>
pfad is null ? null : Path.GetFileName(pfad);
} }
public static class FlaroPolicies public static class FlaroPolicies
+60
View File
@@ -110,6 +110,12 @@ public sealed class MetadataEnricher(
.Where(m => m.LibraryId == libraryId && m.TmdbId != null) .Where(m => m.LibraryId == libraryId && m.TmdbId != null)
.ToDictionaryAsync(m => m.TmdbId!.Value, m => m.Id, ct); .ToDictionaryAsync(m => m.TmdbId!.Value, m => m.Id, ct);
// Welches Jahr im Dateinamen des schon zugeordneten stand — daran
// entscheidet sich, ob ein zweiter Treffer wirklich derselbe Film ist.
var jahreNachId = await db.Movies
.Where(m => m.LibraryId == libraryId && m.TmdbId != null)
.ToDictionaryAsync(m => m.Id, m => m.ParsedYear, ct);
var zusammenzulegen = new List<(Guid Sieger, Guid Verlierer)>(); var zusammenzulegen = new List<(Guid Sieger, Guid Verlierer)>();
foreach (var film in offen) foreach (var film in offen)
@@ -155,11 +161,41 @@ public sealed class MetadataEnricher(
// aus wie „die Spracheinstellung wirkt nicht". // aus wie „die Spracheinstellung wirkt nicht".
if (vergeben.TryGetValue(gefunden.ProviderId, out var schonDa) && schonDa != film.Id) if (vergeben.TryGetValue(gefunden.ProviderId, out var schonDa) && schonDa != film.Id)
{ {
// **Verschiedene Jahre heissen: verschiedene Filme.**
//
// „…Heiligtümer des Todes Teil 1 (2010)" und „Teil 2 (2011)"
// landen bei TMDB auf demselben Treffer, wenn ohne Jahr
// gesucht werden musste — die Suche liefert für beide Titel
// Teil 2 zuerst. Zusammengelegt verschwand damit ein ganzer
// Film als „zweite Fassung" des anderen, und niemand erfuhr
// davon. Gemessen am echten Bestand: 448 Filme und 13 zweite
// Fassungen bei 461 Dateien.
//
// Das Jahr aus dem Dateinamen ändert sich nie und ist deshalb
// der verlässlichste Unterschied, den es hier gibt. Kennt nur
// einer von beiden eines, sagt das nichts — dann bleibt es
// beim Zusammenlegen, denn „Despicable Me" und „Ich einfach
// unverbesserlich" sind wirklich derselbe Film.
if (JahreWidersprechen(film.ParsedYear, jahreNachId.GetValueOrDefault(schonDa)))
{
fortschritt?.Report(new Scanschritt(Scanphase.Vermerk,
$"„{film.ParsedTitle ?? film.Title}“ ({film.ParsedYear}) und ein anderer Titel "
+ $"führen beide auf {gefunden.ProviderId} — verschiedene Jahre, also nicht "
+ "zusammengelegt. Dieser bleibt ohne Zuordnung."));
durch++;
continue;
}
zusammenzulegen.Add((Sieger: schonDa, Verlierer: film.Id)); zusammenzulegen.Add((Sieger: schonDa, Verlierer: film.Id));
continue; continue;
} }
vergeben[gefunden.ProviderId] = film.Id; vergeben[gefunden.ProviderId] = film.Id;
// **Auch das Jahr, und zwar sofort.** Die beiden Tabellen müssen
// im Gleichschritt wachsen: wird nur `vergeben` fortgeschrieben,
// findet der zweite Film zwar den ersten, kennt aber dessen Jahr
// nicht — und legt wieder zusammen, was nicht zusammengehört.
jahreNachId[film.Id] = film.ParsedYear;
film.TmdbId = gefunden.ProviderId; film.TmdbId = gefunden.ProviderId;
film.ImdbId = gefunden.ImdbId; film.ImdbId = gefunden.ImdbId;
// **Ein Titel von Hand bleibt stehen.** Wie bei den Bildern: ohne // **Ein Titel von Hand bleibt stehen.** Wie bei den Bildern: ohne
@@ -237,6 +273,30 @@ public sealed class MetadataEnricher(
return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo)); return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo));
} }
/// <summary>Ob zwei Jahre einander widersprechen.</summary>
///
/// <remarks>
/// <para>
/// <b>Nur wenn beide bekannt sind.</b> Eine Lücke ist keine Aussage: wer
/// seine Dateien ohne Jahr benennt, bekäme sonst nie eine Dublette
/// erkannt — und „Despicable Me" und „Ich einfach unverbesserlich"
/// gehören wirklich zusammen.
/// </para>
/// <para>
/// <b>Dafür schon ab einem Jahr Abstand, und das ist gemessen.</b> Erst
/// stand hier eine Toleranz von einem Jahr — für den Fall, dass eine
/// Erstaufführung im Dezember hier im Januar läuft. Genau die Toleranz
/// verschluckte den Fall, um den es geht: „Heiligtümer des Todes Teil 1"
/// ist von 2010, „Teil 2" von 2011. Zwei Dateien, deren Namen
/// verschiedene Jahre nennen, sind verschiedene Filme — und wenn doch
/// nicht, steht der zweite in der Liste der nicht zugeordneten Titel und
/// lässt sich in zwei Klicks geradeziehen. Das ist allemal besser, als
/// ihn lautlos verschwinden zu lassen.
/// </para>
/// </remarks>
private static bool JahreWidersprechen(int? eines, int? anderes) =>
eines is { } a && anderes is { } b && a != b;
private async Task<int> SerienZuordnen( private async Task<int> SerienZuordnen(
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct, Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null) string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null)
@@ -377,6 +377,141 @@ public class MetadataTests
return null!; return null!;
} }
/* ---------- Zwei Teile, ein Treffer ---------- */
/// <summary>Zwei Filme verschiedener Jahre werden nicht zusammengelegt.</summary>
///
/// <remarks>
/// <para>
/// <b>Aus dem echten Bestand, und es kostete drei Filme.</b> „Harry Potter
/// und die Heiligtümer des Todes Teil 1" und „Teil 2" landen bei TMDB auf
/// demselben Treffer, sobald ohne Jahr gesucht werden muss — gemessen:
/// die Suche liefert für <i>beide</i> Titel Teil 2 zuerst. Weil zwei
/// Einträge dieselbe Kennung nicht teilen dürfen, wurde der zweite als
/// „zweite Fassung" des ersten geschluckt. In der Übersicht standen dann
/// 448 Filme und 13 zweite Fassungen, in Plex 451 Filme — und nirgends
/// stand, welche drei fehlen.
/// </para>
/// <para>
/// Das Jahr aus dem Dateinamen ändert sich nie. Widersprechen sich zwei,
/// sind es verschiedene Filme, und dann bleibt der zweite lieber ohne
/// Zuordnung — sichtbar in der Liste der nicht zugeordneten Titel — als
/// lautlos verschwunden.
/// </para>
/// </remarks>
[Test]
public async Task Zwei_Teile_mit_verschiedenen_Jahren_bleiben_zwei_Filme()
{
Datei("Heiligtuemer des Todes Teil 1 (2010)/Heiligtuemer des Todes Teil 1 (2010).mkv");
Datei("Heiligtuemer des Todes Teil 2 (2011)/Heiligtuemer des Todes Teil 2 (2011).mkv");
// Die Quelle liefert für jede Suche denselben Film — genau wie TMDB
// es ohne Jahr tut.
var anbieter = new VorgegebenerAnbieter
{
Film = new MovieMetadata(12445, "Heiligtümer des Todes – Teil 2", 2011, null, null, null),
};
using var server = MitAnbieter(anbieter);
var (client, bibliothek) = await Einrichten(server, "Movies");
await Scannen(client, bibliothek);
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.That(filme!.Items, Has.Count.EqualTo(2),
"zwei Dateien, zwei Jahre — also zwei Filme, nicht einer mit zwei Fassungen");
var ohne = await client.GetFromJsonAsync<List<OhneZuordnung>>(
$"/api/v1/libraries/{bibliothek}/unmatched");
Assert.That(ohne, Has.Count.EqualTo(1),
"der zweite bleibt ohne Zuordnung — und steht dafür in der Liste");
}
/// <summary>Derselbe Film in zwei Ordnern wird weiterhin zusammengelegt.</summary>
///
/// <remarks>
/// <b>Die Gegenprobe, und sie ist wichtiger als sie aussieht.</b> „Despicable
/// Me" und „Ich einfach unverbesserlich" sind wirklich derselbe Film —
/// gleiches Jahr, zwei Ordner. Diesen Fall mit zu verbieten hiesse, einen
/// Fehler durch einen anderen zu ersetzen.
/// </remarks>
[Test]
public async Task Derselbe_Film_in_zwei_Ordnern_wird_weiter_zusammengelegt()
{
Datei("Despicable Me (2010)/Despicable Me (2010).mkv");
Datei("Ich einfach unverbesserlich (2010)/Ich einfach unverbesserlich (2010).mkv");
var anbieter = new VorgegebenerAnbieter
{
Film = new MovieMetadata(20352, "Ich einfach unverbesserlich", 2010, null, null, null),
};
using var server = MitAnbieter(anbieter);
var (client, bibliothek) = await Einrichten(server, "Movies");
await Scannen(client, bibliothek);
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.Multiple(() =>
{
Assert.That(filme!.Items, Has.Count.EqualTo(1), "ein Film");
Assert.That(filme.Items[0].Files, Has.Count.EqualTo(2), "mit zwei Dateien");
});
}
/// <summary>Ohne Jahr im Namen wird weiter zusammengelegt.</summary>
///
/// <remarks>
/// <b>Eine Lücke ist keine Aussage.</b> Wer seine Dateien ohne Jahr
/// benennt, bekäme sonst nie eine Dublette erkannt — und zwei Ordner
/// desselben Films blieben für immer zwei Einträge.
/// </remarks>
[Test]
public async Task Ohne_Jahr_im_Namen_wird_weiter_zusammengelegt()
{
Datei("Despicable Me/Despicable Me.mkv");
Datei("Ich einfach unverbesserlich/Ich einfach unverbesserlich.mkv");
var anbieter = new VorgegebenerAnbieter
{
Film = new MovieMetadata(20352, "Ich einfach unverbesserlich", 2010, null, null, null),
};
using var server = MitAnbieter(anbieter);
var (client, bibliothek) = await Einrichten(server, "Movies");
await Scannen(client, bibliothek);
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.That(filme!.Items, Has.Count.EqualTo(1));
}
/// <summary>Titel ohne Zuordnung stehen in einer Liste — mit Dateinamen.</summary>
[Test]
public async Task Nicht_zugeordnete_Filme_stehen_in_der_Liste()
{
Datei("Ein Mitschnitt ohne Titel/Ein Mitschnitt ohne Titel.mkv");
// Die Quelle findet nichts.
var anbieter = new VorgegebenerAnbieter { Film = null };
using var server = MitAnbieter(anbieter);
var (client, bibliothek) = await Einrichten(server, "Movies");
await Scannen(client, bibliothek);
var ohne = await client.GetFromJsonAsync<List<OhneZuordnung>>(
$"/api/v1/libraries/{bibliothek}/unmatched");
Assert.That(ohne, Has.Count.EqualTo(1));
Assert.Multiple(() =>
{
Assert.That(ohne![0].Art, Is.EqualTo("movie"));
Assert.That(ohne[0].Dateien, Is.EqualTo(1));
Assert.That(ohne[0].Beispiel, Does.EndWith(".mkv"));
Assert.That(ohne[0].Beispiel, Does.Not.Contain("/"), "nur der Dateiname, nicht der Pfad");
});
}
/// <summary>Eine falsche Zuordnung darf ihre eigene Korrektur nicht verhindern.</summary> /// <summary>Eine falsche Zuordnung darf ihre eigene Korrektur nicht verhindern.</summary>
/// ///
/// <remarks> /// <remarks>
+32
View File
@@ -794,6 +794,38 @@ export type Unzugeordnet = { gesamt: number; dateien: UnzugeordneteDatei[] };
export const unzugeordnet = (bibliothek: string, anzahl = 300) => export const unzugeordnet = (bibliothek: string, anzahl = 300) =>
anfrage<Unzugeordnet>(`/libraries/${bibliothek}/unassigned?take=${anzahl}`); anfrage<Unzugeordnet>(`/libraries/${bibliothek}/unassigned?take=${anzahl}`);
/** Ein Titel, zu dem die Metadatenquelle nichts hergab. */
export type OhneZuordnung = {
id: string;
/** „movie" oder „series" — damit der Klick weiss, wohin. */
art: 'movie' | 'series';
titel: string;
jahr: number | null;
dateien: number;
/** Ein Dateiname, damit man den Titel wiedererkennt. */
beispiel: string | null;
};
/** Titel, die zwar dastehen, aber ohne Metadaten — unter ihrem Dateinamen.
*
* **Nicht zu verwechseln mit `unzugeordnet`.** Das sind Dateien ganz ohne
* Titel; bei einer Filmbibliothek ist diese Liste immer leer, weil der Scan
* für jede Datei einen Eintrag anlegt. Hier geht es um Einträge, die es gibt,
* zu denen die Quelle aber nichts fand. */
export const ohneZuordnung = (bibliothek: string, anzahl = 300) =>
anfrage<OhneZuordnung[]>(`/libraries/${bibliothek}/unmatched?take=${anzahl}`);
export type Mehrfachtitel = {
id: string; art: 'movie' | 'series';
titel: string; jahr: number | null;
dateien: string[];
};
export type Mehrfachbericht = { gesamt: number; titel: Mehrfachtitel[] };
/** Titel mit mehr als einer Datei — dort versteckt sich falsch Zusammengelegtes. */
export const mehrfachfassungen = (bibliothek: string, anzahl = 300) =>
anfrage<Mehrfachbericht>(`/libraries/${bibliothek}/versions?take=${anzahl}`);
export type VermissteDatei = { export type VermissteDatei = {
fileId: string; path: string; versionLabel: string | null; fileId: string; path: string; versionLabel: string | null;
missingSince: string; libraryName: string; missingSince: string; libraryName: string;
+59 -47
View File
@@ -4,9 +4,10 @@ import {
type Film, type Serie, type Film, type Serie,
} from './api'; } from './api';
import { esc, knopf, feld, fehlerzeile, meldung, hinweisZeigen } from './util'; import { esc, knopf, feld, fehlerzeile, meldung, hinweisZeigen } from './util';
import { icons, icon } from './icons'; import { icons } from './icons';
import { bilddialog } from './bilder'; import { bilderflaeche } from './bilder';
import { zuordnungsdialog } from './zuordnung'; import { zuordnungsflaeche } from './zuordnung';
import { beimSeitenwechselSchliessen } from './dialog';
/** Was sich nach dem Bearbeiten geändert hat. */ /** Was sich nach dem Bearbeiten geändert hat. */
export type Bearbeitungsergebnis = { export type Bearbeitungsergebnis = {
@@ -18,6 +19,8 @@ export type Bearbeitungsergebnis = {
verschmolzen: boolean; verschmolzen: boolean;
/** Welcher Eintrag das jetzt ist — nach einem Zusammenlegen ein anderer. */ /** Welcher Eintrag das jetzt ist — nach einem Zusammenlegen ein anderer. */
id: string; id: string;
/** Ob im Bilderreiter etwas getauscht wurde. */
bildGeaendert: boolean;
}; };
/** Alles zu einem Titel an einer Stelle. /** Alles zu einem Titel an einer Stelle.
@@ -69,12 +72,14 @@ export function bearbeiten(
sortiertitel: zuletzt.sortTitle, sortiertitel: zuletzt.sortTitle,
verschmolzen, verschmolzen,
id: jetzige, id: jetzige,
bildGeaendert: geaendertesBild,
}); });
} }
}); });
d.addEventListener('click', e => { if (e.target === d) d.close(); }); d.addEventListener('click', e => { if (e.target === d) d.close(); });
document.body.appendChild(d); document.body.appendChild(d);
d.showModal(); d.showModal();
beimSeitenwechselSchliessen(d);
/** Der zuletzt geladene Stand — daraus entsteht die Rückmeldung. */ /** Der zuletzt geladene Stand — daraus entsteht die Rückmeldung. */
let zuletzt: Film | Serie | null = null; let zuletzt: Film | Serie | null = null;
@@ -92,18 +97,63 @@ export function bearbeiten(
zeichnen(zuletzt); zeichnen(zuletzt);
} }
/** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Änderung. */
let reiter: 'angaben' | 'bilder' | 'zuordnung' = 'angaben';
function zeichnen(eintrag: Film | Serie): void { function zeichnen(eintrag: Film | Serie): void {
koerper.innerHTML = ''; koerper.innerHTML = '';
h.textContent = `Bearbeiten — ${eintrag.title}`; h.textContent = `Bearbeiten — ${eintrag.title}`;
koerper.append( const leiste = document.createElement('nav');
kopfzeile(eintrag), leiste.className = 'reiter';
abschnittTitel(eintrag), ([
abschnittSortierung(eintrag), ['angaben', 'Angaben'],
abschnittWeiteres(eintrag), ['bilder', 'Bilder'],
); ['zuordnung', 'Zuordnung'],
] as const).forEach(([wert, text]) => {
const b = document.createElement('button');
b.textContent = text;
b.classList.toggle('check', wert === reiter);
b.onclick = () => { reiter = wert; zeichnen(eintrag); };
leiste.appendChild(b);
});
const buehne = document.createElement('div');
buehne.className = 'bearb-buehne';
koerper.append(kopfzeile(eintrag), leiste, buehne);
// **Alles im selben Fenster.**
//
// Bilder und Zuordnung gingen vorher als zweiter Dialog über dem ersten
// auf — ein Fenster über einem Fenster, und man wusste nicht mehr, was
// man eigentlich zumacht. Es sind dieselben Flächen, nur hier gezeichnet.
if (reiter === 'angaben') {
buehne.append(abschnittTitel(eintrag), abschnittSortierung(eintrag));
return;
}
if (reiter === 'bilder') {
bilderflaeche(buehne, art, jetzige, () => { geaendertesBild = true; });
return;
}
zuordnungsflaeche(buehne, art, jetzige, eintrag.title, null, stand => {
if (stand.merged) {
// Diesen Eintrag gibt es nicht mehr; weiterzubearbeiten hiesse, auf
// ein Loch zu zeigen. Der Dialog folgt dem überlebenden.
verschmolzen = true;
jetzige = stand.id;
hinweisZeigen(`Mit „${stand.title}" zusammengelegt.`);
}
reiter = 'angaben';
void laden();
});
} }
/** Ob im Bilderreiter etwas getauscht wurde — die Kachel will das wissen. */
let geaendertesBild = false;
/* ---------- Kopf: Plakat und was zugeordnet ist ---------- */ /* ---------- Kopf: Plakat und was zugeordnet ist ---------- */
function kopfzeile(eintrag: Film | Serie): HTMLElement { function kopfzeile(eintrag: Film | Serie): HTMLElement {
@@ -212,44 +262,6 @@ export function bearbeiten(
return block; return block;
} }
/* ---------- Bilder und Zuordnung ---------- */
function abschnittWeiteres(eintrag: Film | Serie): HTMLElement {
const block = abschnitt('Bilder und Zuordnung',
'Plakat, Hintergrund und Schriftzug — und welchem Eintrag der '
+ 'Metadatenquelle dieser Titel entspricht.');
const reihe = document.createElement('div');
reihe.className = 'knopfreihe';
const bilder = knopf('Bilder bearbeiten', 'btn');
bilder.prepend(icon('stift'));
// **Der Dialog bleibt offen dahinter.** Wer das Plakat wechselt, ist mit
// dem Bearbeiten meist nicht fertig — ihn hier zuzumachen hiesse, danach
// wieder von vorn anzufangen.
bilder.onclick = () => bilddialog(art, jetzige, eintrag.title, () => void laden());
const zuordnung = knopf('Zuordnung korrigieren', 'btn');
zuordnung.prepend(icon('suche'));
// **Ohne Jahr.** Das angezeigte stammt aus der Zuordnung, die gerade
// korrigiert werden soll — bei einem falschen Treffer grenzt es die Suche
// auf genau das falsche Jahr ein.
zuordnung.onclick = () => zuordnungsdialog(art, jetzige, eintrag.title, null, stand => {
if (stand.merged) {
// Diesen Eintrag gibt es nicht mehr; weiterzubearbeiten hiesse, auf
// ein Loch zu zeigen. Der Dialog folgt dem überlebenden.
verschmolzen = true;
jetzige = stand.id;
hinweisZeigen(`Mit „${stand.title}" zusammengelegt.`);
}
void laden();
});
reihe.append(bilder, zuordnung);
block.appendChild(reihe);
return block;
}
/* ---------- Hilfsbausteine ---------- */ /* ---------- Hilfsbausteine ---------- */
function abschnitt(titel: string, text: string): HTMLElement { function abschnitt(titel: string, text: string): HTMLElement {
+23 -2
View File
@@ -1,6 +1,7 @@
import { bildAdresse, bildauswahl, bildSetzen, bildHochladen, type Bildkandidat } from './api'; import { bildAdresse, bildauswahl, bildSetzen, bildHochladen, type Bildkandidat } from './api';
import { knopf, fehlerzeile, meldung } from './util'; import { knopf, fehlerzeile, meldung } from './util';
import { icons } from './icons'; import { icons } from './icons';
import { beimSeitenwechselSchliessen } from './dialog';
type Bildart = 'poster' | 'backdrop' | 'logo'; type Bildart = 'poster' | 'backdrop' | 'logo';
@@ -49,13 +50,33 @@ export function bilddialog(
const koerper = document.createElement('div'); const koerper = document.createElement('div');
koerper.className = 'dlg-koerper'; koerper.className = 'dlg-koerper';
koerper.innerHTML = '<div class="spinner"></div>';
d.append(kopf, koerper); d.append(kopf, koerper);
d.addEventListener('close', () => { d.remove(); if (geaendert) fertig?.(); }); d.addEventListener('close', () => { d.remove(); if (geaendert) fertig?.(); });
d.addEventListener('click', e => { if (e.target === d) d.close(); }); d.addEventListener('click', e => { if (e.target === d) d.close(); });
document.body.appendChild(d); document.body.appendChild(d);
d.showModal(); d.showModal();
beimSeitenwechselSchliessen(d);
bilderflaeche(koerper, art, id, () => { geaendert = true; });
}
/** Dieselbe Bildauswahl, aber in eine beliebige Fläche gezeichnet.
*
* **Damit sie als Reiter in „Bearbeiten" stehen kann.**
*
* Vorher ging von dort ein zweiter Dialog über dem ersten auf — ein Fenster
* über einem Fenster, und man wusste nicht mehr, was man eigentlich zumacht.
* Der eigene Dialog bleibt für alle, die nur die Bilder wollen; er ist jetzt
* nur noch ein Rahmen um diese Fläche.
*/
export function bilderflaeche(
koerper: HTMLElement,
art: 'movies' | 'series',
id: string,
beiAenderung?: () => void,
): void {
koerper.innerHTML = '<div class="spinner"></div>';
/** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Wahl. */ /** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Wahl. */
let offen: Bildart = 'poster'; let offen: Bildart = 'poster';
@@ -205,7 +226,7 @@ export function bilddialog(
b.disabled = true; b.disabled = true;
try { try {
await bildSetzen(art, id, bildart, kandidat!.url); await bildSetzen(art, id, bildart, kandidat!.url);
geaendert = true; beiAenderung?.();
await laden(); await laden();
} }
catch (fehler) { catch (fehler) {
+6 -20
View File
@@ -5,9 +5,7 @@ import {
import { esc, anteil, hinweisZeigen } from './util'; import { esc, anteil, hinweisZeigen } from './util';
import { icons } from './icons'; import { icons } from './icons';
import { kontextmenue, menueAnZiel, type Menuepunkt } from './menue'; import { kontextmenue, menueAnZiel, type Menuepunkt } from './menue';
import { bilddialog } from './bilder';
import { zuordnungsdialog } from './zuordnung'; import { zuordnungsdialog } from './zuordnung';
import { titeldialog } from './titel';
import { bearbeiten } from './bearbeiten'; import { bearbeiten } from './bearbeiten';
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */ /** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
@@ -339,28 +337,16 @@ function kachelpunkte(
hinweisZeigen(`„${ergebnis.titel}" wurde mit einem vorhandenen Eintrag zusammengelegt.`); hinweisZeigen(`„${ergebnis.titel}" wurde mit einem vorhandenen Eintrag zusammengelegt.`);
return; return;
} }
// Auch ein getauschtes Plakat kommt damit mit — `kachelAuffrischen`
// holt den Eintrag ohnehin frisch.
void kachelAuffrischen(k, knopf); void kachelAuffrischen(k, knopf);
}), }),
}); });
punkte.push({ // **Nur noch diese eine Abkürzung.** Titel und Bilder standen zusätzlich
text: 'Bilder bearbeiten', // im Menü, seit es „Bearbeiten" gibt aber doppelt — und ein Menü mit zwei
icon: 'stift', // Wegen zur selben Sache ist eines zu viel. Die Zuordnung bleibt, weil
// **Die Kachel holt sich das neue Plakat selbst.** Vorher blieb das // sie der häufigste Grund ist, überhaupt etwas anzufassen.
// alte stehen, bis man die ganze Oberfläche neu lud.
tun: () => bilddialog(art, k.id, k.titel, () => void kachelAuffrischen(k, knopf)),
});
punkte.push({
text: 'Titel ändern',
icon: 'stift',
// **Nicht jeder Titel steht bei TMDB.** Ein Mitschnitt hat gar keinen,
// und mancher heisst im Haushalt anders. Bisher blieb nur der Dateiname.
tun: () => titeldialog(art, k.id, k.titel, neu => {
k.titel = neu;
const text = knopf.querySelector('.kacheltext strong');
if (text) text.textContent = neu;
}),
});
punkte.push({ punkte.push({
text: 'Zuordnung korrigieren', text: 'Zuordnung korrigieren',
icon: 'suche', icon: 'suche',
+21
View File
@@ -427,3 +427,24 @@ export function nachfragen(optionen: {
tun: () => optionen.tun(), tun: () => optionen.tun(),
}); });
} }
/** Schliesst einen Dialog, wenn die Seite gewechselt wird.
*
* **Ein `<dialog>` hängt an `document.body`, nicht am Inhaltsbereich.** Der
* Router tauscht darunter die Seite aus, und das Fenster bleibt darüber
* stehen — über einer Seite, zu der es nicht mehr gehört, und mit einem
* Hintergrund, der sich nicht bedienen lässt. Gesehen beim Prüfen: ein
* Bearbeiten-Dialog stand noch über der Verwaltung.
*
* Wird beim Schliessen von selbst wieder abgemeldet — sonst sammelt sich je
* geöffnetem Dialog ein Zuhörer am Fenster an.
*/
export function beimSeitenwechselSchliessen(d: HTMLDialogElement): void {
const zu = (): void => d.close();
window.addEventListener('hashchange', zu);
window.addEventListener('popstate', zu);
d.addEventListener('close', () => {
window.removeEventListener('hashchange', zu);
window.removeEventListener('popstate', zu);
});
}
+27
View File
@@ -1582,3 +1582,30 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
@media (max-width: 560px) { @media (max-width: 560px) {
.bearb-abschnitt .knopfreihe .feld { flex: 1 1 100%; } .bearb-abschnitt .knopfreihe .feld { flex: 1 1 100%; }
} }
/* Die Bühne hinter den Reitern im Bearbeiten-Dialog. */
.bearb-buehne { margin-top: var(--s-regular); }
/* Die Reiterleiste sitzt hier enger als über einer ganzen Seite. */
.dlg .reiter { margin: 0; border-bottom: 1px solid var(--line); }
/* ---------- Ohne Zuordnung: eine Liste zum Durchgehen ---------- */
.ohnezuordnung {
display: flex; flex-direction: column; gap: 2px;
margin-top: var(--s-snug);
max-height: 320px; overflow-y: auto;
}
.ohnezuordnung-zeile {
display: flex; align-items: baseline; gap: var(--s-regular);
width: 100%; text-align: left; cursor: pointer;
background: none; border: 0; color: var(--text);
padding: 7px var(--s-snug); border-radius: var(--r-control);
}
.ohnezuordnung-zeile:hover,
.ohnezuordnung-zeile:focus-visible { background: var(--raised); }
.oz-titel { font-size: 14px; flex: 0 0 auto; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Der Dateiname steht daneben — daran erkennt man, was gemeint ist. */
.oz-dazu {
font-size: 12px; color: var(--text-muted); flex: 1 1 auto;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
+117 -2
View File
@@ -9,7 +9,7 @@ import {
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
servernameSetzen, systemauskunft, servernameSetzen, systemauskunft,
laufendeWiedergaben, serverzustand, bildAdresse, laufendeWiedergaben, serverzustand, bildAdresse,
speicherorte, bildspeicherLeeren, unzugeordnet, speicherorte, bildspeicherLeeren, unzugeordnet, ohneZuordnung, mehrfachfassungen,
sprachverteilung, abweichendeAuffrischen, sprachverteilung, abweichendeAuffrischen,
type Bibliothek, type Wiedergabe, ApiFehler, type Bibliothek, type Wiedergabe, ApiFehler,
} from '../api'; } from '../api';
@@ -38,7 +38,8 @@ export function verwaltung(
const wurzel = document.createElement('div'); const wurzel = document.createElement('div');
wurzel.append(dashboard(signal), sprachbereich(), pruefbereich(geaendert), wurzel.append(dashboard(signal), sprachbereich(), pruefbereich(geaendert),
vermisstenbereich(geaendert), bibliotheksbereich(geaendert, signal), benutzerbereich(), vermisstenbereich(geaendert), bibliotheksbereich(geaendert, signal), benutzerbereich(),
serverbereich(geaendert), speicherbereich(), unzugeordnetbereich(), serverbereich(geaendert), speicherbereich(), ohneZuordnungBereich(),
unzugeordnetbereich(),
protokollbereich()); protokollbereich());
return wurzel; return wurzel;
} }
@@ -407,6 +408,120 @@ function pruefbereich(geaendert: () => void): HTMLElement {
* fehlt die Folgennummer, muss umbenannt werden; fehlt der Serienname, genügt * fehlt die Folgennummer, muss umbenannt werden; fehlt der Serienname, genügt
* ein Ordner darum herum. * ein Ordner darum herum.
*/ */
/** Titel, zu denen die Metadatenquelle nichts hergab.
*
* **Weil die Zahl allein nichts erklärt.** In der Übersicht standen 448
* Filme, in Plex 451 — und nirgends, welche drei fehlen oder warum. Ein Film
* ohne Zuordnung steht zwar in der Mediathek, aber unter seinem Dateinamen
* und ohne Plakat; wer ihn sucht, findet ihn nicht, weil er anders heisst.
*
* Daneben: Titel mit mehr als einer Datei. Zwei Dateien an einem Film sind
* meist zwei Fassungen und in Ordnung — manchmal aber zwei verschiedene
* Filme, die bei der Quelle auf denselben Treffer liefen. An den Dateinamen
* sieht man das in zwei Sekunden.
*/
function ohneZuordnungBereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.classList.add('hidden');
bereich.appendChild(abschnitt('Ohne Zuordnung'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void laden();
async function laden(): Promise<void> {
let alle;
try { alle = await bibliotheken(); }
catch { return; }
const ergebnisse = await Promise.all(alle.map(async b => {
try {
const [offen, mehrfach] = await Promise.all([
ohneZuordnung(b.id), mehrfachfassungen(b.id),
]);
return { b, offen, mehrfach };
} catch { return null; }
}));
const mitFunden = ergebnisse
.filter(e => e !== null)
.filter(e => e!.offen.length > 0 || e!.mehrfach.gesamt > 0);
if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; }
bereich.classList.remove('hidden');
liste.innerHTML = '';
mitFunden.forEach(e => {
const { b, offen, mehrfach } = e!;
if (offen.length > 0) {
liste.appendChild(gruppe(
`${b.name} — ${anzahl(offen.length, 'Titel')} ohne Zuordnung`,
'Diese stehen unter ihrem Dateinamen in der Mediathek. '
+ 'Über „Zuordnung korrigieren" lassen sie sich geradeziehen.',
offen.map(t => ({
titel: t.titel + (t.jahr ? ` (${t.jahr})` : ''),
dazu: t.beispiel ?? `${anzahl(t.dateien, 'Datei')}`,
// **Hier bearbeiten, nicht woandershin springen.** Wer eine
// Liste durchgeht, will nach dem Geradeziehen beim nächsten
// weitermachen — nicht jedes Mal zurücknavigieren.
tun: () => bearbeiten(t.art === 'series' ? 'series' : 'movies', t.id, () => void laden()),
}))));
}
if (mehrfach.gesamt > 0) {
liste.appendChild(gruppe(
`${b.name} — ${anzahl(mehrfach.gesamt, 'Titel')} mit mehreren Dateien`,
'Meist zwei Fassungen desselben Films. Stehen hier aber zwei '
+ 'verschiedene Filme, lief einer von beiden auf den falschen Treffer — '
+ 'dann hilft „Zuordnung korrigieren".',
mehrfach.titel.map(t => ({
titel: t.titel + (t.jahr ? ` (${t.jahr})` : ''),
dazu: t.dateien.join(' · '),
tun: () => bearbeiten(t.art === 'series' ? 'series' : 'movies', t.id, () => void laden()),
}))));
}
});
}
function anzahl(n: number, wort: string): string {
return `${n} ${n === 1 ? wort : (wort === 'Datei' ? 'Dateien' : wort)}`;
}
function gruppe(
ueberschrift: string, hinweis: string,
zeilen: { titel: string; dazu: string; tun: () => void }[],
): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung';
const kopf = document.createElement('div');
kopf.className = 'zeile';
kopf.innerHTML = `<strong>${esc(ueberschrift)}</strong>`
+ `<span class="winzig">${esc(hinweis)}</span>`;
d.appendChild(kopf);
const innen = document.createElement('div');
innen.className = 'ohnezuordnung';
zeilen.forEach(z => {
const b = document.createElement('button');
b.className = 'ohnezuordnung-zeile';
b.innerHTML = `<span class="oz-titel">${esc(z.titel)}</span>`
+ `<span class="oz-dazu">${esc(z.dazu)}</span>`;
b.onclick = z.tun;
innen.appendChild(b);
});
d.appendChild(innen);
return d;
}
return bereich;
}
function unzugeordnetbereich(): HTMLElement { function unzugeordnetbereich(): HTMLElement {
const bereich = document.createElement('section'); const bereich = document.createElement('section');
const kopf = abschnitt('Nicht zugeordnet'); const kopf = abschnitt('Nicht zugeordnet');
+34 -7
View File
@@ -1,6 +1,7 @@
import { vorschlaege, zuordnen, herkunft, type Zuordnungsstand } from './api'; import { vorschlaege, zuordnen, herkunft, type Zuordnungsstand } from './api';
import { esc, knopf, feld, fehlerzeile, leer, meldung } from './util'; import { esc, knopf, feld, fehlerzeile, leer, meldung } from './util';
import { icons } from './icons'; import { icons } from './icons';
import { beimSeitenwechselSchliessen } from './dialog';
/** Einen Titel einem anderen Eintrag der Metadatenquelle zuordnen. /** Einen Titel einem anderen Eintrag der Metadatenquelle zuordnen.
* *
@@ -40,6 +41,39 @@ export function zuordnungsdialog(
const koerper = document.createElement('div'); const koerper = document.createElement('div');
koerper.className = 'dlg-koerper'; koerper.className = 'dlg-koerper';
d.append(kopf, koerper);
d.addEventListener('close', () => d.remove());
d.addEventListener('click', e => { if (e.target === d) d.close(); });
document.body.appendChild(d);
d.showModal();
beimSeitenwechselSchliessen(d);
zuordnungsflaeche(koerper, art, id, titel, jahr, stand => {
d.close();
fertig?.(stand);
});
}
/** Dieselbe Zuordnungssuche, aber in eine beliebige Fläche gezeichnet.
*
* **Damit sie als Reiter in „Bearbeiten" stehen kann** — statt als zweiter
* Dialog über dem ersten. Der eigene Dialog bleibt für alle, die nur die
* Zuordnung wollen; er ist jetzt nur noch ein Rahmen um diese Fläche.
*
* `fertig` wird nach einer übernommenen Zuordnung gerufen. Ob daraufhin etwas
* geschlossen wird, entscheidet der Aufrufer — in einem Reiter wäre das
* falsch, in einem eigenen Dialog richtig.
*/
export function zuordnungsflaeche(
koerper: HTMLElement,
art: 'movies' | 'series',
id: string,
titel: string,
jahr: number | null,
fertig?: (stand: Zuordnungsstand) => void,
): void {
koerper.innerHTML = '';
// **Woher der Titel stammt, über allem anderen.** // **Woher der Titel stammt, über allem anderen.**
// //
// Liegt die Zuordnung völlig daneben, sagt der angezeigte Titel nichts mehr // Liegt die Zuordnung völlig daneben, sagt der angezeigte Titel nichts mehr
@@ -92,16 +126,10 @@ export function zuordnungsdialog(
treffer.className = 'einstellungen'; treffer.className = 'einstellungen';
koerper.append(quelle, hinweis, reihe, treffer); koerper.append(quelle, hinweis, reihe, treffer);
d.append(kopf, koerper);
d.addEventListener('close', () => d.remove());
d.addEventListener('click', e => { if (e.target === d) d.close(); });
// Enter im Suchfeld sucht, statt den Dialog zu schliessen. // Enter im Suchfeld sucht, statt den Dialog zu schliessen.
suche.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); void fragen(); } }; suche.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); void fragen(); } };
document.body.appendChild(d);
d.showModal();
suchen.onclick = () => void fragen(); suchen.onclick = () => void fragen();
void fragen(); void fragen();
@@ -165,7 +193,6 @@ export function zuordnungsdialog(
lauf.innerHTML = stand.merged lauf.innerHTML = stand.merged
? '<span>Übernommen und mit dem vorhandenen Eintrag zusammengelegt.</span>' ? '<span>Übernommen und mit dem vorhandenen Eintrag zusammengelegt.</span>'
: '<span>Übernommen.</span>'; : '<span>Übernommen.</span>';
d.close();
fertig?.(stand); fertig?.(stand);
} catch (fehler) { } catch (fehler) {
lauf.remove(); lauf.remove();