Verschobene Dateien hinterliessen eine Leiche
Seit verschwundene Dateien markiert statt gelöscht werden, blieb nach jeder Umbenennung die alte Zeile als "vermisst" stehen. Damit galt die alte Folge nicht als verwaist, die alte Serie nicht als leer — und nach dem Umbenennen eines Serienordners stand alles doppelt da. Jetzt erkennt der Scan eine Verschiebung an Grösse und Änderungszeit und hängt den vorhandenen Eintrag um, statt einen zweiten anzulegen. Der Zeitstempel musste dafür auf Sekunden gerundet werden: er geht durch die Datenbank und kommt nicht auf den Tick genau zurück. Mit Vergleich auf Gleichheit schlug die Erkennung ausnahmslos fehl — der Test zeigte es, nachdem er zweimal dasselbe falsche Ergebnis lieferte. Grösse und Zeit müssen beide stimmen, und das Merkmal muss eindeutig sein. Grösse allein reichte nicht: zwei gleich grosse Dateien gibt es, und die zu vertauschen wäre schlimmer als eine Leiche. Dazu: die Zahl der Titel läuft während eines Scans mit, statt erst am Ende zu springen. Alle fünf Runden, nicht jede — ein Abruf je Sekunde für eine Zahl wäre Verschwendung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b64497acd5
commit
fe44d45323
@@ -94,9 +94,32 @@ public sealed class LibraryScanner(
|
|||||||
? await db.Episodes.Where(f => serien.Select(s => s.Id).Contains(f.SeriesId)).ToListAsync(ct)
|
? 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;
|
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;
|
var durch = 0;
|
||||||
|
|
||||||
foreach (var datei in ergebnis.Files)
|
foreach (var datei in ergebnis.Files)
|
||||||
@@ -155,6 +178,29 @@ public sealed class LibraryScanner(
|
|||||||
if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++;
|
if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++;
|
||||||
eintrag = bekannt;
|
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
|
else
|
||||||
{
|
{
|
||||||
eintrag = new MediaFile
|
eintrag = new MediaFile
|
||||||
@@ -211,9 +257,11 @@ public sealed class LibraryScanner(
|
|||||||
await VerwaisteAufraeumen(libraryId, ct);
|
await VerwaisteAufraeumen(libraryId, ct);
|
||||||
|
|
||||||
log.LogInformation(
|
log.LogInformation(
|
||||||
"Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Geändert} geändert, " +
|
"Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Verschoben} verschoben, " +
|
||||||
"{Verschwunden} nicht mehr gefunden, {Unerkannt} unerkannt, {Untersucht} untersucht.",
|
"{Geändert} geändert, {Verschwunden} nicht mehr gefunden, {Unerkannt} unerkannt, " +
|
||||||
bibliothek.Name, ergebnis.Files.Count, neu, geändert, verschwunden, unerkannt, untersucht);
|
"{Untersucht} untersucht.",
|
||||||
|
bibliothek.Name, ergebnis.Files.Count, neu, verschoben, geändert, verschwunden,
|
||||||
|
unerkannt, untersucht);
|
||||||
|
|
||||||
return new ScanReport(
|
return new ScanReport(
|
||||||
ergebnis.Files.Count, neu, geändert, 0, unerkannt, untersucht, ergebnis.Problems,
|
ergebnis.Files.Count, neu, geändert, 0, unerkannt, untersucht, ergebnis.Problems,
|
||||||
|
|||||||
@@ -302,6 +302,46 @@ public class ScanTests
|
|||||||
Assert.That(filme!.Total, Is.EqualTo(2));
|
Assert.That(filme!.Total, Is.EqualTo(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Eine Serie, die ihren Namen verliert, bleibt nicht als Leiche stehen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Der Weg, den ein vorhandener Bestand nach dem Update geht.</b> 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.
|
||||||
|
/// </remarks>
|
||||||
|
[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<Page<SeriesResponse>>("/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<Page<SeriesResponse>>("/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));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>4K und 1080p sind ein Film mit zwei Dateien, nicht zwei Filme.</summary>
|
/// <summary>4K und 1080p sind ein Film mit zwei Dateien, nicht zwei Filme.</summary>
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Zwei_Fassungen_desselben_Films_bleiben_ein_Eintrag()
|
public async Task Zwei_Fassungen_desselben_Films_bleiben_ein_Eintrag()
|
||||||
|
|||||||
@@ -680,6 +680,11 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
|||||||
if (s.state === 'running') stand.textContent = s.step ?? 'Wird durchsucht…';
|
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 === 'enriching') stand.textContent = s.step ?? 'Metadaten werden geholt…';
|
||||||
else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; }
|
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') {
|
else if (s.state === 'finished') {
|
||||||
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
|
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
|
||||||
if (s.unrecognised) teile.push(`${s.unrecognised} ohne Folgennummer`);
|
if (s.unrecognised) teile.push(`${s.unrecognised} ohne Folgennummer`);
|
||||||
|
|||||||
Reference in New Issue
Block a user