Not Found war weder deutsch noch wahr

Beim Korrigieren einer Zuordnung geht der bearbeitete Eintrag im vorhandenen
auf — er wird gelöscht. Die Antwort nannte aber nur die Daten des
überlebenden, nicht seine Kennung, und so zeigte die Kachel weiter auf etwas,
das es nicht mehr gab. Wer sie dann bearbeiten wollte, bekam „Not Found".

Zwei Fehler in einer Meldung. Der Server sagt jetzt, welcher Eintrag
überlebt hat und ob zusammengelegt wurde; die Kachel nimmt sich weg statt
auf ein Loch zu zeigen, und sagt wohin.

Und das Englische: in `detail` steht der Satz, den der Server selbst
geschrieben hat — deutsch und mit einem Hinweis, was zu tun ist. `title` ist
dagegen der Statusname aus dem Rahmenwerk, und genau der wurde angezeigt, wo
`detail` fehlte. Er wird nicht mehr gelesen; wo der Server nichts zu sagen
hat, schreibt der Client selbst einen deutschen Satz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-05 22:05:36 +02:00
co-authored by Claude Opus 5
parent c46063637f
commit e70c33f7e2
6 changed files with 206 additions and 19 deletions
+19 -1
View File
@@ -381,8 +381,26 @@ public sealed record MatchCandidate(
public sealed record ApplyMatchRequest(int TmdbId);
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
///
/// <param name="Id">
/// <b>Welcher Eintrag das jetzt ist.</b> Meist der, den man korrigiert hat —
/// nach einem Zusammenlegen aber der überlebende, und der hat eine andere
/// Kennung.
/// </param>
/// <param name="Merged">
/// <b>Ob dabei zwei Einträge zu einem wurden.</b>
///
/// <para>
/// 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.
/// </para>
/// </param>
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 -------------------------------------
@@ -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);
}
@@ -149,6 +149,104 @@ public class MatchCorrectionTests
var danach = await client.GetFromJsonAsync<Page<MovieResponse>>("/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);
});
}
/// <summary>Wird beim Korrigieren zusammengelegt, sagt die Antwort es auch.</summary>
///
/// <remarks>
/// <para>
/// <b>Sonst zeigt die Mediathek weiter auf einen gelöschten Eintrag.</b> 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.
/// </para>
/// <para>
/// <b>Der Ablauf ist der aus dem Betrieb</b>, nicht der kürzeste: zwei
/// Ordner gleichzeitig legt schon der Scan zusammen. Hier kommt der zweite
/// dazu, <i>nachdem</i> der erste korrigiert wurde — dann stehen zwei
/// Einträge mit verschiedenen Kennungen da, und die Korrektur des zweiten
/// führt sie zusammen.
/// </para>
/// </remarks>
[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<List<LibraryResponse>>("/api/v1/libraries");
await Scannen(client, bibliotheken![0].Id);
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/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<MatchState>())!;
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");
});
}
/// <summary>Legt einen weiteren Filmordner im Prüfbestand an.</summary>
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<ScanStatus>($"/api/v1/libraries/{bibliothek}/scan");
if (stand!.State == "finished") return;
await Task.Delay(50);
}
Assert.Fail("Der Scan wurde nicht fertig.");
}
/// <summary>Die Korrektur überlebt einen Neuaufbau der Datenbank.</summary>
+53 -6
View File
@@ -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<T>(pfad: string, opts: RequestInit = {}): Promise<T> {
}
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<Herkunft>(`/${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<Zuordnungsstand>(
`/${art}/${id}/match`, { method: 'PUT', body: JSON.stringify({ tmdbId }) });
/* ---------- Hilfsmittel ---------- */
+16 -3
View File
@@ -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);
}),
});
}
+12 -5
View File
@@ -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 = '<span>Übernommen.</span>';
lauf.innerHTML = stand.merged
? '<span>Übernommen und mit dem vorhandenen Eintrag zusammengelegt.</span>'
: '<span>Übernommen.</span>';
d.close();
fertig?.();
fertig?.(stand);
} catch (fehler) {
lauf.remove();
zeile.classList.remove('laeuft');