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 `