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