diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 7792dc0..c8bff95 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -23,6 +23,28 @@ public sealed record Speicherort( /// Dateien, die zu keinem Titel gehören — mit Begründung. public sealed record Unzugeordnet(int Gesamt, IReadOnlyList Dateien); +/// Ein Titel, zu dem die Metadatenquelle nichts hergab. +/// +/// „movie" oder „series" — damit der Client weiss, wohin. +/// Wie viele Dateien daran hängen. +/// Ein Dateiname, damit man den Titel wiedererkennt. +/// +/// +/// Weil die Zahl allein nichts erklärt. 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. +/// +public sealed record OhneZuordnung( + Guid Id, string Art, string Titel, int? Jahr, int Dateien, string? Beispiel); + +/// Titel mit mehr als einer Datei — dort versteckt sich Falsches. +public sealed record Mehrfachbericht( + int Gesamt, IReadOnlyList Titel); + +/// Die Namen der Dateien, die an diesem einen Titel hängen. +public sealed record Mehrfachtitel( + Guid Id, string Art, string Titel, int? Jahr, IReadOnlyList Dateien); + public sealed record UnzugeordneteDatei(Guid Id, string Pfad, string Grund); /// Wie viele Titel in welcher Sprache vorliegen. diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index 5d175ce..4281adf 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -227,6 +227,92 @@ public static class ManagementEndpoints f.Id, f.Path, FileNames.WarumNicht(f.Path, bibliothek.Path))).ToList())); }).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) => { var liste = await db.MediaFiles.AsNoTracking() @@ -395,6 +481,10 @@ public static class ManagementEndpoints return Results.NoContent(); }).RequireAuthorization(FlaroPolicies.Administrator); } + + /// Nur der Dateiname — der volle Pfad geht niemanden etwas an. + private static string? Dateiname(string? pfad) => + pfad is null ? null : Path.GetFileName(pfad); } public static class FlaroPolicies diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs index 00863f7..8f12184 100644 --- a/server/src/Flaro.Data/MetadataEnricher.cs +++ b/server/src/Flaro.Data/MetadataEnricher.cs @@ -110,6 +110,12 @@ public sealed class MetadataEnricher( .Where(m => m.LibraryId == libraryId && m.TmdbId != null) .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)>(); foreach (var film in offen) @@ -155,11 +161,41 @@ public sealed class MetadataEnricher( // aus wie „die Spracheinstellung wirkt nicht". 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)); continue; } 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.ImdbId = gefunden.ImdbId; // **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)); } + /// Ob zwei Jahre einander widersprechen. + /// + /// + /// + /// Nur wenn beide bekannt sind. 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. + /// + /// + /// Dafür schon ab einem Jahr Abstand, und das ist gemessen. 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. + /// + /// + private static bool JahreWidersprechen(int? eines, int? anderes) => + eines is { } a && anderes is { } b && a != b; + private async Task SerienZuordnen( Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct, string? nurSprache = null, IReadOnlySet? nurDiese = null) diff --git a/server/tests/Flaro.Api.Tests/MetadataTests.cs b/server/tests/Flaro.Api.Tests/MetadataTests.cs index df71937..4d861f4 100644 --- a/server/tests/Flaro.Api.Tests/MetadataTests.cs +++ b/server/tests/Flaro.Api.Tests/MetadataTests.cs @@ -377,6 +377,141 @@ public class MetadataTests return null!; } + /* ---------- Zwei Teile, ein Treffer ---------- */ + + /// Zwei Filme verschiedener Jahre werden nicht zusammengelegt. + /// + /// + /// + /// Aus dem echten Bestand, und es kostete drei Filme. „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 beide 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. + /// + /// + /// 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. + /// + /// + [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>("/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>( + $"/api/v1/libraries/{bibliothek}/unmatched"); + + Assert.That(ohne, Has.Count.EqualTo(1), + "der zweite bleibt ohne Zuordnung — und steht dafür in der Liste"); + } + + /// Derselbe Film in zwei Ordnern wird weiterhin zusammengelegt. + /// + /// + /// Die Gegenprobe, und sie ist wichtiger als sie aussieht. „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. + /// + [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>("/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"); + }); + } + + /// Ohne Jahr im Namen wird weiter zusammengelegt. + /// + /// + /// Eine Lücke ist keine Aussage. 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. + /// + [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>("/api/v1/movies"); + Assert.That(filme!.Items, Has.Count.EqualTo(1)); + } + + /// Titel ohne Zuordnung stehen in einer Liste — mit Dateinamen. + [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>( + $"/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"); + }); + } + /// Eine falsche Zuordnung darf ihre eigene Korrektur nicht verhindern. /// /// diff --git a/web/app/src/api.ts b/web/app/src/api.ts index e2516d8..449de7c 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -794,6 +794,38 @@ export type Unzugeordnet = { gesamt: number; dateien: UnzugeordneteDatei[] }; export const unzugeordnet = (bibliothek: string, anzahl = 300) => anfrage(`/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(`/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(`/libraries/${bibliothek}/versions?take=${anzahl}`); + export type VermissteDatei = { fileId: string; path: string; versionLabel: string | null; missingSince: string; libraryName: string; diff --git a/web/app/src/bearbeiten.ts b/web/app/src/bearbeiten.ts index 24ec047..cdb6913 100644 --- a/web/app/src/bearbeiten.ts +++ b/web/app/src/bearbeiten.ts @@ -4,9 +4,10 @@ import { type Film, type Serie, } from './api'; import { esc, knopf, feld, fehlerzeile, meldung, hinweisZeigen } from './util'; -import { icons, icon } from './icons'; -import { bilddialog } from './bilder'; -import { zuordnungsdialog } from './zuordnung'; +import { icons } from './icons'; +import { bilderflaeche } from './bilder'; +import { zuordnungsflaeche } from './zuordnung'; +import { beimSeitenwechselSchliessen } from './dialog'; /** Was sich nach dem Bearbeiten geändert hat. */ export type Bearbeitungsergebnis = { @@ -18,6 +19,8 @@ export type Bearbeitungsergebnis = { verschmolzen: boolean; /** Welcher Eintrag das jetzt ist — nach einem Zusammenlegen ein anderer. */ id: string; + /** Ob im Bilderreiter etwas getauscht wurde. */ + bildGeaendert: boolean; }; /** Alles zu einem Titel an einer Stelle. @@ -69,12 +72,14 @@ export function bearbeiten( sortiertitel: zuletzt.sortTitle, verschmolzen, id: jetzige, + bildGeaendert: geaendertesBild, }); } }); d.addEventListener('click', e => { if (e.target === d) d.close(); }); document.body.appendChild(d); d.showModal(); + beimSeitenwechselSchliessen(d); /** Der zuletzt geladene Stand — daraus entsteht die Rückmeldung. */ let zuletzt: Film | Serie | null = null; @@ -92,18 +97,63 @@ export function bearbeiten( zeichnen(zuletzt); } + /** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Änderung. */ + let reiter: 'angaben' | 'bilder' | 'zuordnung' = 'angaben'; + function zeichnen(eintrag: Film | Serie): void { koerper.innerHTML = ''; h.textContent = `Bearbeiten — ${eintrag.title}`; - koerper.append( - kopfzeile(eintrag), - abschnittTitel(eintrag), - abschnittSortierung(eintrag), - abschnittWeiteres(eintrag), - ); + const leiste = document.createElement('nav'); + leiste.className = 'reiter'; + ([ + ['angaben', 'Angaben'], + ['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 ---------- */ function kopfzeile(eintrag: Film | Serie): HTMLElement { @@ -212,44 +262,6 @@ export function bearbeiten( 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 ---------- */ function abschnitt(titel: string, text: string): HTMLElement { diff --git a/web/app/src/bilder.ts b/web/app/src/bilder.ts index 9cc4c40..60fa7e9 100644 --- a/web/app/src/bilder.ts +++ b/web/app/src/bilder.ts @@ -1,6 +1,7 @@ import { bildAdresse, bildauswahl, bildSetzen, bildHochladen, type Bildkandidat } from './api'; import { knopf, fehlerzeile, meldung } from './util'; import { icons } from './icons'; +import { beimSeitenwechselSchliessen } from './dialog'; type Bildart = 'poster' | 'backdrop' | 'logo'; @@ -49,13 +50,33 @@ export function bilddialog( const koerper = document.createElement('div'); koerper.className = 'dlg-koerper'; - koerper.innerHTML = '
'; d.append(kopf, koerper); d.addEventListener('close', () => { d.remove(); if (geaendert) fertig?.(); }); d.addEventListener('click', e => { if (e.target === d) d.close(); }); document.body.appendChild(d); 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 = '
'; /** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Wahl. */ let offen: Bildart = 'poster'; @@ -205,7 +226,7 @@ export function bilddialog( b.disabled = true; try { await bildSetzen(art, id, bildart, kandidat!.url); - geaendert = true; + beiAenderung?.(); await laden(); } catch (fehler) { diff --git a/web/app/src/card.ts b/web/app/src/card.ts index 4a93510..37615a2 100644 --- a/web/app/src/card.ts +++ b/web/app/src/card.ts @@ -5,9 +5,7 @@ import { import { esc, anteil, hinweisZeigen } from './util'; import { icons } from './icons'; import { kontextmenue, menueAnZiel, type Menuepunkt } from './menue'; -import { bilddialog } from './bilder'; import { zuordnungsdialog } from './zuordnung'; -import { titeldialog } from './titel'; import { bearbeiten } from './bearbeiten'; /** 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.`); return; } + // Auch ein getauschtes Plakat kommt damit mit — `kachelAuffrischen` + // holt den Eintrag ohnehin frisch. void kachelAuffrischen(k, knopf); }), }); - punkte.push({ - text: 'Bilder bearbeiten', - icon: 'stift', - // **Die Kachel holt sich das neue Plakat selbst.** Vorher blieb das - // 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; - }), - }); + // **Nur noch diese eine Abkürzung.** Titel und Bilder standen zusätzlich + // im Menü, seit es „Bearbeiten" gibt aber doppelt — und ein Menü mit zwei + // Wegen zur selben Sache ist eines zu viel. Die Zuordnung bleibt, weil + // sie der häufigste Grund ist, überhaupt etwas anzufassen. punkte.push({ text: 'Zuordnung korrigieren', icon: 'suche', diff --git a/web/app/src/dialog.ts b/web/app/src/dialog.ts index fd954f0..953cba3 100644 --- a/web/app/src/dialog.ts +++ b/web/app/src/dialog.ts @@ -427,3 +427,24 @@ export function nachfragen(optionen: { tun: () => optionen.tun(), }); } + +/** Schliesst einen Dialog, wenn die Seite gewechselt wird. + * + * **Ein `` 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); + }); +} diff --git a/web/app/src/style.css b/web/app/src/style.css index c5c18a0..6c3b8ee 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1582,3 +1582,30 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; } @media (max-width: 560px) { .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; +} diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index eca5490..b67d488 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -9,7 +9,7 @@ import { benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, - speicherorte, bildspeicherLeeren, unzugeordnet, + speicherorte, bildspeicherLeeren, unzugeordnet, ohneZuordnung, mehrfachfassungen, sprachverteilung, abweichendeAuffrischen, type Bibliothek, type Wiedergabe, ApiFehler, } from '../api'; @@ -38,7 +38,8 @@ export function verwaltung( const wurzel = document.createElement('div'); wurzel.append(dashboard(signal), sprachbereich(), pruefbereich(geaendert), vermisstenbereich(geaendert), bibliotheksbereich(geaendert, signal), benutzerbereich(), - serverbereich(geaendert), speicherbereich(), unzugeordnetbereich(), + serverbereich(geaendert), speicherbereich(), ohneZuordnungBereich(), + unzugeordnetbereich(), protokollbereich()); return wurzel; } @@ -407,6 +408,120 @@ function pruefbereich(geaendert: () => void): HTMLElement { * fehlt die Folgennummer, muss umbenannt werden; fehlt der Serienname, genügt * 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 { + 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 = `${esc(ueberschrift)}` + + `${esc(hinweis)}`; + 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 = `${esc(z.titel)}` + + `${esc(z.dazu)}`; + b.onclick = z.tun; + innen.appendChild(b); + }); + d.appendChild(innen); + + return d; + } + + return bereich; +} + function unzugeordnetbereich(): HTMLElement { const bereich = document.createElement('section'); const kopf = abschnitt('Nicht zugeordnet'); diff --git a/web/app/src/zuordnung.ts b/web/app/src/zuordnung.ts index ae6a5a3..baa4893 100644 --- a/web/app/src/zuordnung.ts +++ b/web/app/src/zuordnung.ts @@ -1,6 +1,7 @@ import { vorschlaege, zuordnen, herkunft, type Zuordnungsstand } from './api'; import { esc, knopf, feld, fehlerzeile, leer, meldung } from './util'; import { icons } from './icons'; +import { beimSeitenwechselSchliessen } from './dialog'; /** Einen Titel einem anderen Eintrag der Metadatenquelle zuordnen. * @@ -40,6 +41,39 @@ export function zuordnungsdialog( const koerper = document.createElement('div'); 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.** // // Liegt die Zuordnung völlig daneben, sagt der angezeigte Titel nichts mehr @@ -92,16 +126,10 @@ export function zuordnungsdialog( treffer.className = 'einstellungen'; 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. suche.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); void fragen(); } }; - document.body.appendChild(d); - d.showModal(); - suchen.onclick = () => void fragen(); void fragen(); @@ -165,7 +193,6 @@ export function zuordnungsdialog( lauf.innerHTML = stand.merged ? 'Übernommen und mit dem vorhandenen Eintrag zusammengelegt.' : 'Übernommen.'; - d.close(); fertig?.(stand); } catch (fehler) { lauf.remove();