diff --git a/server/src/Flaro.Data/LibraryScanner.cs b/server/src/Flaro.Data/LibraryScanner.cs index f992e55..f485ada 100644 --- a/server/src/Flaro.Data/LibraryScanner.cs +++ b/server/src/Flaro.Data/LibraryScanner.cs @@ -94,9 +94,32 @@ public sealed class LibraryScanner( ? await db.Episodes.Where(f => serien.Select(s => s.Id).Contains(f.SeriesId)).ToListAsync(ct) : []; - int neu = 0, geändert = 0, unerkannt = 0, untersucht = 0; + int neu = 0, geändert = 0, unerkannt = 0, untersucht = 0, verschoben = 0; var seitLetztemSchreiben = 0; + // **Dateien nach Grösse und Änderungszeit, für das Erkennen von + // Verschiebungen.** + // + // Seit verschwundene Dateien markiert statt gelöscht werden, hinterlässt + // jede Umbenennung eine Leiche: die alte Zeile bleibt als „vermisst" + // stehen, die alte Folge gilt damit nicht als verwaist, und die alte + // Serie bleibt in der Mediathek. Nach dem Umbenennen eines + // Serienordners stand alles doppelt da. + // + // Grösse **und** Zeitstempel müssen stimmen. Grösse allein reichte + // nicht: zwei gleich grosse Dateien gibt es, und die zu vertauschen + // wäre schlimmer als eine Leiche. + // Auf Sekunden gerundet: der Zeitstempel geht durch die Datenbank und + // kommt nicht auf den Tick genau zurück. Ein Vergleich auf Gleichheit + // schlug deshalb immer fehl, und keine Verschiebung wurde je erkannt. + static (long, long) Merkmal(long bytes, DateTimeOffset geaendert) => + (bytes, geaendert.ToUnixTimeSeconds()); + + var nachMerkmal = bekannteDateien.Values + .GroupBy(f => Merkmal(f.SizeBytes, f.ModifiedAt)) + .Where(g => g.Count() == 1) // Mehrdeutiges bleibt liegen. + .ToDictionary(g => g.Key, g => g.Single()); + var durch = 0; foreach (var datei in ergebnis.Files) @@ -155,6 +178,29 @@ public sealed class LibraryScanner( if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++; eintrag = bekannt; } + else if (nachMerkmal.TryGetValue(Merkmal(datei.SizeBytes, datei.ModifiedAt), out var umgezogen) + && umgezogen.SeenAt < laufZeit) + { + // **Dieselbe Datei, neuer Pfad.** Der Eintrag zieht um, statt + // dass ein neuer entsteht und der alte als vermisst + // zurückbleibt. Sonst stünde nach dem Umbenennen eines Ordners + // alles doppelt da — einmal richtig, einmal als Leiche. + nachMerkmal.Remove(Merkmal(datei.SizeBytes, datei.ModifiedAt)); + bekannteDateien.Remove(umgezogen.Path); + + umgezogen.Path = datei.Path; + umgezogen.MovieId = filmId; + umgezogen.EpisodeId = folgeId; + umgezogen.SeenAt = laufZeit; + umgezogen.MissingSince = null; + umgezogen.VersionLabel = FileNames.Fassung(datei.Path) ?? umgezogen.VersionLabel; + + eintrag = umgezogen; + // Nicht erneut untersuchen: der Inhalt ist derselbe, nur der + // Name ist anders. + mussUntersucht = umgezogen.ProbedAt is null; + verschoben++; + } else { eintrag = new MediaFile @@ -211,9 +257,11 @@ public sealed class LibraryScanner( await VerwaisteAufraeumen(libraryId, ct); log.LogInformation( - "Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Geändert} geändert, " + - "{Verschwunden} nicht mehr gefunden, {Unerkannt} unerkannt, {Untersucht} untersucht.", - bibliothek.Name, ergebnis.Files.Count, neu, geändert, verschwunden, unerkannt, untersucht); + "Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Verschoben} verschoben, " + + "{Geändert} geändert, {Verschwunden} nicht mehr gefunden, {Unerkannt} unerkannt, " + + "{Untersucht} untersucht.", + bibliothek.Name, ergebnis.Files.Count, neu, verschoben, geändert, verschwunden, + unerkannt, untersucht); return new ScanReport( ergebnis.Files.Count, neu, geändert, 0, unerkannt, untersucht, ergebnis.Problems, diff --git a/server/tests/Flaro.Api.Tests/ScanTests.cs b/server/tests/Flaro.Api.Tests/ScanTests.cs index a323949..5c3efbf 100644 --- a/server/tests/Flaro.Api.Tests/ScanTests.cs +++ b/server/tests/Flaro.Api.Tests/ScanTests.cs @@ -302,6 +302,46 @@ public class ScanTests Assert.That(filme!.Total, Is.EqualTo(2)); } + /// Eine Serie, die ihren Namen verliert, bleibt nicht als Leiche stehen. + /// + /// + /// Der Weg, den ein vorhandener Bestand nach dem Update geht. Vorher + /// bestimmte der Dateiname die Serie; eine Datei direkt in der Wurzel + /// ergibt das bis heute. Wandert sie in einen Ordner, gehört sie zu diesem + /// — und der alte Eintrag muss verschwinden, statt als leere Serie in der + /// Mediathek zu bleiben. + /// + [Test] + public async Task Eine_verwaiste_Serie_verschwindet_beim_naechsten_Lauf() + { + Datei("S01E01 Kapitel 1.mkv"); + + using var server = new FlaroServer(); + var (client, bibliothek) = await MitBibliothek(server, "Shows"); + await Scannen(client, bibliothek); + + var vorher = await client.GetFromJsonAsync>("/api/v1/series"); + Assert.That(vorher!.Total, Is.EqualTo(1), "Ohne Ordner bleibt nur der Dateiname."); + var alterName = vorher.Items[0].Title; + + // **Verschieben, nicht löschen und neu anlegen.** Beim Verschieben + // bleibt der Zeitstempel erhalten — und genau daran erkennt der Scan, + // dass es dieselbe Datei ist. Ein Test, der sie neu anlegt, prüft + // etwas anderes als das, was ein Mensch tut. + var alt = Path.Combine(_bestand, "S01E01 Kapitel 1.mkv"); + var neuerPfad = Path.Combine(_bestand, "Nachtstreife", "S01E01 Kapitel 1.mkv"); + Directory.CreateDirectory(Path.GetDirectoryName(neuerPfad)!); + File.Move(alt, neuerPfad); + + await Scannen(client, bibliothek); + + var nachher = await client.GetFromJsonAsync>("/api/v1/series"); + Assert.That(nachher!.Total, Is.EqualTo(1), "Die alte Serie darf nicht danebenstehen."); + Assert.That(nachher.Items[0].Title, Is.EqualTo("Nachtstreife")); + Assert.That(nachher.Items[0].Title, Is.Not.EqualTo(alterName)); + Assert.That(nachher.Items[0].EpisodeCount, Is.EqualTo(1)); + } + /// 4K und 1080p sind ein Film mit zwei Dateien, nicht zwei Filme. [Test] public async Task Zwei_Fassungen_desselben_Films_bleiben_ein_Eintrag() diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 1416f89..7fb8199 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -680,6 +680,11 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle if (s.state === 'running') stand.textContent = s.step ?? 'Wird durchsucht…'; else if (s.state === 'enriching') stand.textContent = s.step ?? 'Metadaten werden geholt…'; else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; } + + // **Die Zahl mitlaufen lassen, nicht erst am Ende.** Der Scan schreibt + // laufend; wer dabei zusieht, soll das auch sehen. Alle fünf Runden, + // nicht jede: ein Abruf je Sekunde für eine Zahl wäre Verschwendung. + if (melden && i > 0 && i % 5 === 0) await laden(); else if (s.state === 'finished') { const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`]; if (s.unrecognised) teile.push(`${s.unrecognised} ohne Folgennummer`);