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');