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