diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs
index 7a66beb..95fc3b9 100644
--- a/server/src/Flaro.Api/Endpoints/Contracts.cs
+++ b/server/src/Flaro.Api/Endpoints/Contracts.cs
@@ -381,8 +381,26 @@ public sealed record MatchCandidate(
public sealed record ApplyMatchRequest(int TmdbId);
/// Wie die Zuordnung jetzt aussieht.
+///
+///
+/// Welcher Eintrag das jetzt ist. Meist der, den man korrigiert hat —
+/// nach einem Zusammenlegen aber der überlebende, und der hat eine andere
+/// Kennung.
+///
+///
+/// Ob dabei zwei Einträge zu einem wurden.
+///
+///
+/// Ohne diese Auskunft stand der korrigierte Titel weiter in der Liste,
+/// obwohl es ihn nicht mehr gab: wer ihn dann bearbeiten wollte, bekam eine
+/// 404 — und zwar als englisches „Not Found", weil auch das nicht stimmte.
+/// Der Client kann die Kachel jetzt wegnehmen, statt auf einen gelöschten
+/// Eintrag zu zeigen.
+///
+///
public sealed record MatchState(
- int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year);
+ int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year,
+ Guid Id = default, bool Merged = false);
// --- Stufe 4: Fortschritt und Zustand -------------------------------------
diff --git a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs
index 78432b3..d2b829a 100644
--- a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs
@@ -183,11 +183,13 @@ public static class MatchEndpoints
var sieger = await db.Movies.AsNoTracking().FirstAsync(m => m.Id == schonDa);
return Results.Ok(new MatchState(
- sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year));
+ sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year,
+ Id: sieger.Id, Merged: true));
}
await anwender.ApplyAsync(film, gefunden);
- return Results.Ok(new MatchState(film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year));
+ return Results.Ok(new MatchState(
+ film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year, Id: film.Id));
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/series/{id:guid}/matches", async (
@@ -231,11 +233,13 @@ public static class MatchEndpoints
var sieger = await db.Series.AsNoTracking().FirstAsync(x => x.Id == schonDa);
return Results.Ok(new MatchState(
- sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year));
+ sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year,
+ Id: sieger.Id, Merged: true));
}
await anwender.ApplyAsync(serie, gefunden);
- return Results.Ok(new MatchState(serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year));
+ return Results.Ok(new MatchState(
+ serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year, Id: serie.Id));
}).RequireAuthorization(FlaroPolicies.Administrator);
}
diff --git a/server/tests/Flaro.Api.Tests/MatchCorrectionTests.cs b/server/tests/Flaro.Api.Tests/MatchCorrectionTests.cs
index c1a4d9d..485ccd8 100644
--- a/server/tests/Flaro.Api.Tests/MatchCorrectionTests.cs
+++ b/server/tests/Flaro.Api.Tests/MatchCorrectionTests.cs
@@ -149,6 +149,104 @@ public class MatchCorrectionTests
var danach = await client.GetFromJsonAsync>("/api/v1/movies");
Assert.That(danach!.Items.Single().Title, Is.EqualTo("Dark"));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(stand.Id, Is.EqualTo(film.Id), "ohne Zusammenlegen bleibt es derselbe Eintrag");
+ Assert.That(stand.Merged, Is.False);
+ });
+ }
+
+ /// Wird beim Korrigieren zusammengelegt, sagt die Antwort es auch.
+ ///
+ ///
+ ///
+ /// Sonst zeigt die Mediathek weiter auf einen gelöschten Eintrag. Der
+ /// korrigierte Film verschwindet beim Zusammenlegen — die Kachel blieb aber
+ /// stehen, und wer sie dann bearbeiten wollte, bekam eine 404. Angezeigt
+ /// wurde dafür „Not Found": englisch, und für etwas, das gar kein Fehler
+ /// ist.
+ ///
+ ///
+ /// Der Ablauf ist der aus dem Betrieb, nicht der kürzeste: zwei
+ /// Ordner gleichzeitig legt schon der Scan zusammen. Hier kommt der zweite
+ /// dazu, nachdem der erste korrigiert wurde — dann stehen zwei
+ /// Einträge mit verschiedenen Kennungen da, und die Korrektur des zweiten
+ /// führt sie zusammen.
+ ///
+ ///
+ [Test]
+ public async Task Ein_Zusammenlegen_beim_Korrigieren_wird_gemeldet()
+ {
+ var (client, film) = await Eingelesen(beiwerkSchreiben: false);
+
+ // Der erste bekommt die richtige Kennung.
+ (await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523)))
+ .EnsureSuccessStatusCode();
+
+ // Jetzt kommt ein zweiter Ordner mit demselben Film dazu.
+ Zweitordner("Dunkel (2017)");
+ var bibliotheken = await client.GetFromJsonAsync>("/api/v1/libraries");
+ await Scannen(client, bibliotheken![0].Id);
+
+ var filme = await client.GetFromJsonAsync>("/api/v1/movies");
+ Assert.That(filme!.Items, Has.Count.EqualTo(2),
+ "verschiedene Kennungen — der Scan legt sie nicht von selbst zusammen");
+
+ var zweiter = filme.Items.Single(m => m.Id != film.Id);
+
+ var antwort = await client.PutAsJsonAsync(
+ $"/api/v1/movies/{zweiter.Id}/match", new ApplyMatchRequest(70523));
+ antwort.EnsureSuccessStatusCode();
+ var stand = (await antwort.Content.ReadFromJsonAsync())!;
+
+ var weg = await client.GetAsync($"/api/v1/movies/{zweiter.Id}");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(stand.Merged, Is.True);
+ Assert.That(stand.Id, Is.EqualTo(film.Id),
+ "der überlebende Eintrag — genau das muss der Client erfahren");
+ Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NotFound),
+ "und der korrigierte ist wirklich weg");
+ });
+ }
+
+ /// Legt einen weiteren Filmordner im Prüfbestand an.
+ private void Zweitordner(string name)
+ {
+ var ordner = Directory.CreateDirectory(Path.Combine(_bestand, name)).FullName;
+ var start = new ProcessStartInfo(Werkzeug("ffmpeg")!)
+ {
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+ foreach (var a in new[]
+ {
+ "-y", "-loglevel", "error",
+ "-f", "lavfi", "-i", "testsrc=duration=1:size=160x120:rate=10",
+ "-c:v", "libx264", "-preset", "ultrafast",
+ Path.Combine(ordner, $"{name}.mkv"),
+ })
+ {
+ start.ArgumentList.Add(a);
+ }
+
+ using var prozess = Process.Start(start)!;
+ prozess.StandardError.ReadToEnd();
+ prozess.WaitForExit();
+ }
+
+ private static async Task Scannen(HttpClient client, Guid bibliothek)
+ {
+ (await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null)).EnsureSuccessStatusCode();
+ for (var i = 0; i < 200; i++)
+ {
+ var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan");
+ if (stand!.State == "finished") return;
+ await Task.Delay(50);
+ }
+ Assert.Fail("Der Scan wurde nicht fertig.");
}
/// Die Korrektur überlebt einen Neuaufbau der Datenbank.
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index 3f5b46c..cc25716 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -191,6 +191,34 @@ export const meineSprache = (): string | null => sitzung.wer?.language ?? null;
/* ---------- Anfragen ---------- */
+/** Ein deutscher Satz zu einem Status, den der Server nicht erklärt hat.
+ *
+ * **Weil „Not Found" keine Auskunft ist.** Wo der Server selbst etwas zu
+ * sagen hat, steht es in `detail` und hat Vorrang. Antwortet er nur mit einer
+ * Zahl, gehört hierher ein Satz — und zwar einer, der sagt, was gerade schief
+ * ging, nicht wie der Status heisst.
+ */
+function satzZu(status: number): string {
+ switch (status) {
+ case 400: return 'Die Anfrage war so nicht zu gebrauchen.';
+ case 401: return 'Die Anmeldung gilt nicht mehr. Melde dich neu an.';
+ case 403: return 'Dafür fehlen die Rechte.';
+ case 404: return 'Das gibt es nicht mehr. Vermutlich wurde es beim letzten '
+ + 'Scan mit einem anderen Eintrag zusammengelegt oder entfernt.';
+ case 409: return 'Das geht gerade nicht — es läuft schon etwas anderes.';
+ case 410: return 'Die Datei dazu liegt nicht mehr da.';
+ case 413: return 'Das ist zu gross.';
+ case 429: return 'Zu viele Anfragen auf einmal. Warte einen Augenblick.';
+ case 504: return 'Der Server hat zu lange gebraucht.';
+ default:
+ return status >= 500
+ ? 'Auf dem Server ist etwas schiefgegangen. Die Einzelheiten stehen '
+ + 'im Protokoll von heute.'
+ : 'Der Server hat die Anfrage abgelehnt. Die Einzelheiten stehen im '
+ + 'Protokoll von heute.';
+ }
+}
+
export class ApiFehler extends Error {
// **Der Name wird gesetzt, nicht geerbt.** Nach dem Bündeln heisst die
// Klasse anders, und `instanceof` über Modulgrenzen ist nicht verlässlich;
@@ -244,13 +272,22 @@ async function anfrage(pfad: string, opts: RequestInit = {}): Promise {
}
if (!antwort.ok) {
- let text = `${antwort.status}`;
+ // **Nur `detail`, niemals `title`.**
+ //
+ // In `detail` steht der Satz, den der Server selbst geschrieben hat —
+ // deutsch und mit einem Hinweis, was zu tun ist. `title` ist dagegen der
+ // englische Statusname aus dem Rahmenwerk: bei einer schlichten 404 steht
+ // dort „Not Found", und genau das stand dann im Bilderdialog. Dasselbe
+ // gilt für `errors`: die Prüftexte des Rahmenwerks sind englisch.
+ let text = '';
try {
const fehler = await antwort.json();
- text = fehler.detail ?? fehler.title
- ?? Object.values(fehler.errors ?? {}).flat().join(' ') ?? text;
- } catch { /* Keine JSON-Antwort — dann bleibt der Status. */ }
- throw new ApiFehler(antwort.status, text);
+ if (typeof fehler?.detail === 'string' && fehler.detail.trim().length > 0) {
+ text = fehler.detail.trim();
+ }
+ } catch { /* Keine JSON-Antwort — dann schreiben wir den Satz selbst. */ }
+
+ throw new ApiFehler(antwort.status, text || satzZu(antwort.status));
}
// **Nicht jede erfolgreiche Antwort hat einen Körper.**
@@ -831,8 +868,18 @@ export type Herkunft = { ordner: string | null; dateien: number; beispiele: stri
export const herkunft = (art: 'movies' | 'series', id: string) =>
anfrage(`/${art}/${id}/herkunft`);
+/** Wie die Zuordnung nach der Korrektur aussieht. */
+export type Zuordnungsstand = {
+ tmdbId: number; imdbId: string | null; locked: boolean;
+ title: string; year: number | null;
+ /** Welcher Eintrag das jetzt ist — nach einem Zusammenlegen ein anderer. */
+ id: string;
+ /** Ob dabei zwei Einträge zu einem wurden. */
+ merged: boolean;
+};
+
export const zuordnen = (art: 'movies' | 'series', id: string, tmdbId: number) =>
- anfrage<{ tmdbId: number; imdbId: string | null; locked: boolean; title: string; year: number | null }>(
+ anfrage(
`/${art}/${id}/match`, { method: 'PUT', body: JSON.stringify({ tmdbId }) });
/* ---------- Hilfsmittel ---------- */
diff --git a/web/app/src/card.ts b/web/app/src/card.ts
index 72b7bae..5484f2a 100644
--- a/web/app/src/card.ts
+++ b/web/app/src/card.ts
@@ -2,7 +2,7 @@ import {
bildAdresse, gesehen, favorit, weglegen, istAdmin, film, serie,
type Film, type Serie, type Angefangen, type Stand,
} from './api';
-import { esc, anteil } from './util';
+import { esc, anteil, hinweisZeigen } from './util';
import { icons } from './icons';
import { kontextmenue, menueAnZiel, type Menuepunkt } from './menue';
import { bilddialog } from './bilder';
@@ -346,12 +346,25 @@ function kachelpunkte(
// korrigiert werden soll — bei einem falschen Treffer grenzt es die
// Suche auf genau das falsche Jahr ein. Ohne Angabe nimmt der Server
// das aus dem Dateinamen.
- tun: () => zuordnungsdialog(art, k.id, k.titel, null,
+ tun: () => zuordnungsdialog(art, k.id, k.titel, null, stand => {
+ // **Zusammengelegt heisst: diesen Eintrag gibt es nicht mehr.**
+ //
+ // Vorher blieb die Kachel stehen und zeigte auf einen gelöschten
+ // Eintrag; wer sie dann bearbeiten wollte, bekam „Not Found" — eine
+ // englische Meldung für etwas, das gar kein Fehler war. Der
+ // überlebende Eintrag steht ohnehin schon in der Liste.
+ if (stand.merged) {
+ knopf.remove();
+ hinweisZeigen(`„${stand.title}" wurde mit dem vorhandenen Eintrag zusammengelegt.`);
+ return;
+ }
+
// **Nur die Kachel, nicht die Seite.** `location.reload()` warf
// Bildlauf, Filter und offene Reiter weg — für einen Titel, der sich
// geändert hat. Bei zwanzig Korrekturen hintereinander ist das
// zwanzigmal von vorn anfangen.
- () => void kachelAuffrischen(k, knopf)),
+ void kachelAuffrischen(k, knopf);
+ }),
});
}
diff --git a/web/app/src/zuordnung.ts b/web/app/src/zuordnung.ts
index c774bb0..ae6a5a3 100644
--- a/web/app/src/zuordnung.ts
+++ b/web/app/src/zuordnung.ts
@@ -1,4 +1,4 @@
-import { vorschlaege, zuordnen, herkunft } from './api';
+import { vorschlaege, zuordnen, herkunft, type Zuordnungsstand } from './api';
import { esc, knopf, feld, fehlerzeile, leer, meldung } from './util';
import { icons } from './icons';
@@ -16,7 +16,12 @@ export function zuordnungsdialog(
id: string,
titel: string,
jahr: number | null,
- fertig?: () => void,
+ /** Wird nach einer übernommenen Zuordnung gerufen — mit dem neuen Stand.
+ *
+ * **Der Stand sagt auch, ob zusammengelegt wurde.** Dann gibt es den
+ * korrigierten Eintrag nicht mehr, und wer weiter auf ihn zeigt, bekommt
+ * beim nächsten Handgriff eine 404. */
+ fertig?: (stand: Zuordnungsstand) => void,
): void {
const d = document.createElement('dialog');
d.className = 'dlg dlg-breit';
@@ -154,12 +159,14 @@ export function zuordnungsdialog(
zeile.appendChild(lauf);
try {
- await zuordnen(art, id, v.tmdbId);
+ const stand = await zuordnen(art, id, v.tmdbId);
// Erst melden, dann schliessen: sonst blitzt nur der Dialog weg,
// und es bleibt offen, ob es geklappt hat.
- lauf.innerHTML = 'Übernommen.';
+ lauf.innerHTML = stand.merged
+ ? 'Übernommen und mit dem vorhandenen Eintrag zusammengelegt.'
+ : 'Übernommen.';
d.close();
- fertig?.();
+ fertig?.(stand);
} catch (fehler) {
lauf.remove();
zeile.classList.remove('laeuft');