diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index b205293..4ba24be 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -20,6 +20,11 @@ public sealed record SystemInfo( public sealed record Speicherort( string Name, string Variable, string Pfad, long Belegt, long? Frei, long? Bilder); +/// Dateien, die zu keinem Titel gehören — mit Begründung. +public sealed record Unzugeordnet(int Gesamt, IReadOnlyList Dateien); + +public sealed record UnzugeordneteDatei(Guid Id, string Pfad, string Grund); + public sealed record ServerNameRequest(string Name); public sealed record SetupRequest(string UserName, string Password); diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index 860fa03..4210b8b 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -1,5 +1,6 @@ using Flaro.Api.Auth; using Flaro.Core.Libraries; +using Flaro.Core.Library; using Flaro.Core.Users; using Flaro.Data; using Microsoft.EntityFrameworkCore; @@ -148,6 +149,35 @@ public static class ManagementEndpoints return betroffen > 0 ? Results.NoContent() : Results.NotFound(); }).RequireAuthorization(FlaroPolicies.Administrator); + // **Was zu keinem Titel gehört.** + // + // Der Scan erfindet keine Serie mehr, wenn weder Ordner noch Dateiname + // eine nennen — nur verschwanden die Dateien damit lautlos. Bei 3559 + // von 5884 war das die halbe Mediathek, und in der Übersicht stand + // eine Zahl, die niemand erklären konnte. Hier stehen sie, mit dem + // Grund dazu. + bibliotheken.MapGet("/{id:guid}/unassigned", async ( + Guid id, FlaroContext db, int take = 300) => + { + var bibliothek = await db.Libraries.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id); + if (bibliothek is null) return Results.NotFound(); + + var offen = db.MediaFiles.AsNoTracking() + .Where(f => f.LibraryId == id && f.MovieId == null && f.EpisodeId == null + && f.MissingSince == null); + + var gesamt = await offen.CountAsync(); + var pfade = await offen.OrderBy(f => f.Path).Take(Math.Clamp(take, 1, 2000)).ToListAsync(); + + // Der Grund wird hier gerechnet, nicht gespeichert: er hängt allein + // am Pfad und an der Benennungsregel. Eine Spalte dafür wäre beim + // nächsten Regelwechsel veraltet, ohne dass es jemand merkt. + return Results.Ok(new Unzugeordnet( + gesamt, + pfade.Select(f => new UnzugeordneteDatei( + f.Id, f.Path, FileNames.WarumNicht(f.Path, bibliothek.Path))).ToList())); + }).RequireAuthorization(FlaroPolicies.Administrator); + bibliotheken.MapGet("/missing", async (FlaroContext db) => { var liste = await db.MediaFiles.AsNoTracking() diff --git a/server/src/Flaro.Core/Library/FileNames.cs b/server/src/Flaro.Core/Library/FileNames.cs index 207a80c..5ca8001 100644 --- a/server/src/Flaro.Core/Library/FileNames.cs +++ b/server/src/Flaro.Core/Library/FileNames.cs @@ -267,6 +267,31 @@ public static partial class FileNames AusOrdner: ausOrdner is not null); } + /// Warum sich eine Datei keiner Serie zuordnen liess. + /// + /// + /// Zwei Gründe, und sie verlangen Verschiedenes. Fehlt die + /// Folgennummer, muss die Datei umbenannt werden. Fehlt der Serienname, + /// genügt ein Ordner darum herum. „Nicht zugeordnet" allein sagt beides + /// nicht — und bei 3559 Dateien ist der Unterschied die ganze Arbeit. + /// + public static string WarumNicht(string dateiname, string? wurzel = null) + { + var roh = Rohname(dateiname); + var treffer = StaffelUndFolge().Match(roh); + if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh); + + if (!treffer.Success) + { + return "keine Folgennummer im Dateinamen (erwartet wird etwa S01E02 oder 1x02)."; + } + + return wurzel is { Length: > 0 } && SerieAusOrdner(dateiname, wurzel) is null + ? "liegt ohne Ordner direkt in der Mediathek, und vor der Folgennummer " + + "steht kein Serienname." + : "vor der Folgennummer steht kein Serienname."; + } + /// Der Serienname aus dem Pfad. /// /// diff --git a/server/src/Flaro.Data/LibraryScanner.cs b/server/src/Flaro.Data/LibraryScanner.cs index 137797f..c3719f3 100644 --- a/server/src/Flaro.Data/LibraryScanner.cs +++ b/server/src/Flaro.Data/LibraryScanner.cs @@ -162,16 +162,25 @@ public sealed class LibraryScanner( var name = FileNames.ParseEpisode(datei.Path, bibliothek.Path); if (name is null) { - // **Nicht raten.** Eine erfundene Staffel 1 landete unter - // einer Serie, zu der die Datei nicht gehört, und dort - // fände sie niemand je wieder. + // **Nicht raten — aber auch nicht verschweigen.** + // + // Eine erfundene Staffel 1 landete unter einer Serie, zu + // der die Datei nicht gehört, und dort fände sie niemand je + // wieder. Die Datei einfach zu überspringen war aber die + // andere Hälfte des Fehlers: 3559 von 5884 Dateien + // verschwanden lautlos, und in der Mediathek stand eine + // Zahl, die keiner erklären konnte. + // + // Jetzt bekommt sie eine Zeile ohne Zuordnung. Damit ist + // sie zählbar, auflistbar und beim nächsten Lauf — oder + // nach einer besseren Regel — nachträglich zuzuordnen. unerkannt++; log.LogInformation("Scan: {Pfad} liess sich keiner Serie zuordnen.", datei.Path); fortschritt?.Report(new Scanschritt(Scanphase.Vermerk, - $"Nicht zugeordnet: {datei.Path} — weder ein Ordner noch der " - + "Dateiname nennen eine Serie.")); - continue; + $"Nicht zugeordnet: {datei.Path} — {FileNames.WarumNicht(datei.Path, bibliothek.Path)}")); } + if (name is not null) + { var vorher = serien.Count; var serie = SerieFinden(serien, libraryId, name.SeriesTitle); @@ -188,6 +197,7 @@ public sealed class LibraryScanner( } folgeId = FolgeFinden(folgen, serie.Id, name).Id; + } } MediaFile eintrag; @@ -264,7 +274,13 @@ public sealed class LibraryScanner( mussUntersucht = true; } - if (mussUntersucht && await UntersuchenAsync(eintrag, laufZeit, ct)) untersucht++; + // **Was keiner Serie gehört, wird nicht untersucht.** Ein + // ffprobe-Durchgang kostet rund fünfzig Millisekunden; bei 3559 + // unzugeordneten Dateien ist das eine halbe Stunde für Angaben, + // die niemand zu sehen bekommt. Sobald die Zuordnung steht, holt + // der nächste Lauf es nach — `ProbedAt` ist dann noch leer. + var gehoertDazu = filmId is not null || folgeId is not null; + if (gehoertDazu && mussUntersucht && await UntersuchenAsync(eintrag, laufZeit, ct)) untersucht++; // **Zwischendurch schreiben, nicht erst am Ende.** // diff --git a/server/tests/Flaro.Api.Tests/ScanTests.cs b/server/tests/Flaro.Api.Tests/ScanTests.cs index 2a096bd..85d7290 100644 --- a/server/tests/Flaro.Api.Tests/ScanTests.cs +++ b/server/tests/Flaro.Api.Tests/ScanTests.cs @@ -464,7 +464,15 @@ public class ScanTests Assert.That(nurStaffel2!.Items, Has.Count.EqualTo(1)); } - /// Was keine Folgennummer trägt, wird nicht geraten. + /// Was keine Folgennummer trägt, wird nicht geraten — aber auch nicht verschwiegen. + /// + /// + /// Beide Hälften gehören zusammen. Eine erfundene Staffel 1 wäre + /// falsch; die Datei lautlos zu überspringen war aber genauso falsch. Bei + /// 3559 von 5884 Dateien verschwand so die halbe Mediathek, ohne dass + /// irgendwo eine Zahl davon erzählte. Added zählt Dateizeilen, und + /// eine bekommt sie jetzt — zugeordnet ist sie deshalb nicht. + /// [Test] public async Task Dateien_ohne_Folgennummer_werden_gezaehlt_aber_nicht_zugeordnet() { @@ -477,7 +485,19 @@ public class ScanTests Assert.That(stand.FilesFound, Is.EqualTo(2)); Assert.That(stand.Unrecognised, Is.EqualTo(1)); - Assert.That(stand.Added, Is.EqualTo(1)); + Assert.That(stand.Added, Is.EqualTo(2), "Beide Dateien bekommen eine Zeile."); + + // Die Serie hat genau eine Folge — der Mitschnitt hängt an keiner. + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + Assert.That(serien!.Total, Is.EqualTo(1)); + Assert.That(serien.Items[0].EpisodeCount, Is.EqualTo(1)); + + // Und er ist auffindbar, statt lautlos zu verschwinden. + var offen = await client.GetFromJsonAsync( + $"/api/v1/libraries/{bibliothek}/unassigned"); + Assert.That(offen!.Gesamt, Is.EqualTo(1)); + Assert.That(offen.Dateien[0].Pfad, Does.EndWith("Irgendein Mitschnitt.mkv")); + Assert.That(offen.Dateien[0].Grund, Does.Contain("keine Folgennummer")); } [Test] diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 300e6e8..3299603 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -643,6 +643,13 @@ export const protokolltage = () => anfrage('/system/logs'); export const protokollAdresse = (tag: string, zeilen?: number) => `/api/v1/system/logs/${tag}` + (zeilen ? `?tail=${zeilen}` : ''); +export type UnzugeordneteDatei = { id: string; pfad: string; grund: string }; +export type Unzugeordnet = { gesamt: number; dateien: UnzugeordneteDatei[] }; + +/** Dateien, die zu keinem Titel gehören — mit Begründung je Datei. */ +export const unzugeordnet = (bibliothek: string, anzahl = 300) => + anfrage(`/libraries/${bibliothek}/unassigned?take=${anzahl}`); + export type VermissteDatei = { fileId: string; path: string; versionLabel: string | null; missingSince: string; libraryName: string; diff --git a/web/app/src/style.css b/web/app/src/style.css index 088af21..1c2600d 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1001,6 +1001,16 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m font-size: var(--t-klein); color: var(--text); word-break: break-all; } +/* Ein aufklappbarer Block je Grund — zugeklappt sieht man die Verteilung, + * aufgeklappt die Pfade. Dreitausend Zeilen am Stück liest niemand. */ +.grundblock { margin-top: var(--s-snug); } +.grundblock summary { + cursor: pointer; padding: 6px 0; color: var(--text-dim); font-size: var(--t-normal); +} +.grundblock summary:hover { color: var(--text); } +.grundblock summary strong { color: var(--accent); margin-right: 6px; } +.grundblock .protokoll { max-height: 220px; margin-top: var(--s-snug); } + .bildblock { display: flex; flex-direction: column; gap: var(--s-snug); } .bildblock h3 { margin: 0; font-size: var(--t-gross); font-weight: 600; flex: 1; } diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index ae4853b..dae8a11 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, + speicherorte, bildspeicherLeeren, unzugeordnet, type Bibliothek, type Wiedergabe, ApiFehler, } from '../api'; import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../util'; @@ -32,7 +32,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(), protokollbereich()); + serverbereich(geaendert), speicherbereich(), unzugeordnetbereich(), + protokollbereich()); return wurzel; } @@ -363,6 +364,116 @@ function pruefbereich(geaendert: () => void): HTMLElement { * Das Containerprotokoll ist nach einem Neustart weg und nur über SSH * erreichbar — die Frage „warum wurde der Film gestern Nacht nicht erkannt?" * lässt sich damit nicht beantworten. */ +/** Dateien, die zu keinem Titel gehören. + * + * **Warum das eine eigene Ansicht verdient.** Der Scan erfindet keine Serie + * mehr, wenn weder Ordner noch Dateiname eine nennen — richtig so, aber die + * Dateien verschwanden damit lautlos. Bei 3559 von 5884 war das die halbe + * Mediathek, und in der Übersicht stand eine Zahl, die niemand erklären + * konnte. + * + * **Mit Grund je Datei**, weil die beiden Gründe Verschiedenes verlangen: + * fehlt die Folgennummer, muss umbenannt werden; fehlt der Serienname, genügt + * ein Ordner darum herum. + */ +function unzugeordnetbereich(): HTMLElement { + const bereich = document.createElement('section'); + const kopf = abschnitt('Nicht zugeordnet'); + bereich.appendChild(kopf); + + const liste = document.createElement('div'); + liste.className = 'einstellungen'; + bereich.appendChild(liste); + + void laden(); + + async function laden(): Promise { + let alle; + try { alle = await bibliotheken(); } + catch { bereich.classList.add('hidden'); return; } + + const ergebnisse = await Promise.all(alle.map(async b => { + try { return { b, offen: await unzugeordnet(b.id) }; } + catch { return null; } + })); + + const mitFunden = ergebnisse.filter(e => e !== null).filter(e => e!.offen.gesamt > 0); + + // Nichts offen heisst: der Abschnitt hat hier nichts verloren. + if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; } + bereich.classList.remove('hidden'); + + liste.innerHTML = ''; + + mitFunden.forEach(eintrag => { + const { b, offen } = eintrag!; + + const d = document.createElement('div'); + d.className = 'einstellung'; + + const oben = document.createElement('div'); + oben.className = 'knopfreihe'; + const text = document.createElement('div'); + text.className = 'zeile'; + text.style.flex = '1'; + text.innerHTML = `${esc(b.name)}` + + `${offen.gesamt} ${offen.gesamt === 1 ? 'Datei' : 'Dateien'} ` + + 'ohne Zuordnung'; + oben.appendChild(text); + + // **Zum Verschicken.** Eine Liste, die man markieren und kopieren muss, + // schickt niemand — und ohne die Pfade lässt sich nicht sagen, woran + // die Erkennung scheitert. + const holen = knopf('Liste herunterladen', 'btn'); + holen.onclick = () => { + const zeilen = offen.dateien.map(f => `${f.pfad}\t${f.grund}`).join('\n'); + const blob = new Blob( + [`# ${b.name} — ${offen.gesamt} ohne Zuordnung, davon ${offen.dateien.length} aufgeführt\n` + + zeilen + '\n'], + { type: 'text/plain;charset=utf-8' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `nicht-zugeordnet-${b.name}.txt`; + a.click(); + URL.revokeObjectURL(a.href); + }; + oben.appendChild(holen); + d.appendChild(oben); + + // **Nach Grund gebündelt, nicht als Liste von dreitausend Zeilen.** So + // sieht man in zwei Zeilen, ob umzubenennen oder einzuordnen ist. + const gruende = new Map(); + offen.dateien.forEach(f => { + if (!gruende.has(f.grund)) gruende.set(f.grund, []); + gruende.get(f.grund)!.push(f.pfad); + }); + + [...gruende.entries()] + .sort((a, c) => c[1].length - a[1].length) + .forEach(([grund, pfade]) => { + const g = document.createElement('details'); + g.className = 'grundblock'; + + const z = document.createElement('summary'); + z.innerHTML = `${pfade.length}× ${esc(grund)}`; + g.appendChild(z); + + const pre = document.createElement('pre'); + pre.className = 'protokoll'; + pre.textContent = pfade.slice(0, 50).join('\n') + + (pfade.length > 50 ? `\n… und ${pfade.length - 50} weitere` : ''); + g.appendChild(pre); + + d.appendChild(g); + }); + + liste.appendChild(d); + }); + } + + return bereich; +} + function protokollbereich(): HTMLElement { const bereich = document.createElement('section'); bereich.appendChild(abschnitt('Protokolle'));