using Flaro.Core.Libraries; using Flaro.Core.Library; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Flaro.Data; public sealed record ScanReport( int FilesFound, int Added, int Updated, int Removed, int Unrecognised, int Probed, IReadOnlyList Problems); /// Gleicht ab, was auf der Platte liegt, mit dem, was in der Datenbank steht. /// /// /// /// Der Scan ist beliebig oft wiederholbar. Zweimal laufen zu lassen /// muss dasselbe Ergebnis haben wie einmal — sonst wächst die Mediathek bei /// jedem Lauf. Dafür sorgt der eindeutige Index auf Bibliothek und Pfad und /// das Nachschlagen statt Anlegen. /// /// /// Weggeräumt wird über den Zeitstempel. Jede vorgefundene Datei /// bekommt die Startzeit des Laufs eingetragen; was danach eine ältere Zeit /// trägt, lag nicht mehr da. Das ist billiger, als sich die gesehenen Pfade zu /// merken — jene Liste wüchse mit dem Bestand. /// /// public sealed class LibraryScanner( FlaroContext db, IMediaProbe sonde, TimeProvider clock, ILogger log) { public async Task ScanAsync(Guid libraryId, CancellationToken ct = default) { var bibliothek = await db.Libraries.FindAsync([libraryId], ct) ?? throw new InvalidOperationException($"Keine Bibliothek mit der Kennung {libraryId}."); var laufZeit = clock.GetUtcNow(); var ergebnis = LibraryWalker.Walk(bibliothek.Path); foreach (var problem in ergebnis.Problems) log.LogWarning("Scan: {Pfad} übersprungen — {Grund}", problem.Path, problem.Reason); // In den Speicher geholt, weil beim Abgleich jede Datei dagegen // nachschlägt. Einzeln zu fragen hiesse: eine Abfrage je Datei, und // SQLite führt die nacheinander aus. var bekannteDateien = await db.MediaFiles .Where(f => f.LibraryId == libraryId) .ToDictionaryAsync(f => f.Path, ct); var filme = bibliothek.Kind == LibraryKind.Movies ? await db.Movies.Where(m => m.LibraryId == libraryId).ToListAsync(ct) : []; var serien = bibliothek.Kind == LibraryKind.Shows ? await db.Series.Where(s => s.LibraryId == libraryId).ToListAsync(ct) : []; var folgen = bibliothek.Kind == LibraryKind.Shows ? 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; foreach (var datei in ergebnis.Files) { ct.ThrowIfCancellationRequested(); Guid? filmId = null, folgeId = null; if (bibliothek.Kind == LibraryKind.Movies) { var name = FileNames.ParseMovie(datei.Path); filmId = FilmFinden(filme, libraryId, name).Id; } else { var name = FileNames.ParseEpisode(datei.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. unerkannt++; log.LogInformation("Scan: {Pfad} trägt keine Folgennummer und bleibt liegen.", datei.Path); continue; } var serie = SerieFinden(serien, libraryId, name.SeriesTitle); folgeId = FolgeFinden(folgen, serie.Id, name).Id; } MediaFile eintrag; bool mussUntersucht; if (bekannteDateien.TryGetValue(datei.Path, out var bekannt)) { var vorher = (bekannt.SizeBytes, bekannt.ModifiedAt, bekannt.MovieId, bekannt.EpisodeId); // **Nur bei Änderung erneut untersuchen.** Ein Durchgang kostet // rund fünfzig Millisekunden; bei vierzigtausend Dateien ist // das über eine halbe Stunde, und bei jedem Lauf dieselbe. mussUntersucht = bekannt.ProbedAt is null || bekannt.SizeBytes != datei.SizeBytes || bekannt.ModifiedAt != datei.ModifiedAt; bekannt.SizeBytes = datei.SizeBytes; bekannt.ModifiedAt = datei.ModifiedAt; bekannt.MovieId = filmId; bekannt.EpisodeId = folgeId; bekannt.SeenAt = laufZeit; if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++; eintrag = bekannt; } else { eintrag = new MediaFile { LibraryId = libraryId, Path = datei.Path, SizeBytes = datei.SizeBytes, ModifiedAt = datei.ModifiedAt, SeenAt = laufZeit, MovieId = filmId, EpisodeId = folgeId, }; db.MediaFiles.Add(eintrag); neu++; mussUntersucht = true; } if (mussUntersucht && await UntersuchenAsync(eintrag, laufZeit, ct)) untersucht++; } await db.SaveChangesAsync(ct); // Was dieser Lauf nicht gesehen hat, liegt nicht mehr da. var entfernt = await db.MediaFiles .Where(f => f.LibraryId == libraryId && f.SeenAt < laufZeit) .ExecuteDeleteAsync(ct); await VerwaisteAufraeumen(libraryId, ct); log.LogInformation( "Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Geändert} geändert, " + "{Entfernt} entfernt, {Unerkannt} unerkannt, {Untersucht} untersucht.", bibliothek.Name, ergebnis.Files.Count, neu, geändert, entfernt, unerkannt, untersucht); return new ScanReport( ergebnis.Files.Count, neu, geändert, entfernt, unerkannt, untersucht, ergebnis.Problems); } /// Liest die Spuren einer Datei und schreibt sie fort. /// /// /// Erst speichern, dann untersuchen. Ein neuer Eintrag hat seine /// Kennung zwar schon, aber die Spuren verweisen darauf — und ohne die /// Datei in der Tabelle liefe das in einen Fremdschlüsselfehler. Die /// Reihenfolge ist deshalb keine Geschmacksfrage. /// /// Scheitert die Untersuchung, bleibt ProbedAt leer und der nächste /// Lauf versucht es erneut. Eine Datei, die gerade noch geschrieben wird, /// ist beim nächsten Mal vielleicht lesbar. /// private async Task UntersuchenAsync(MediaFile datei, DateTimeOffset laufZeit, CancellationToken ct) { var gelesen = await sonde.ProbeAsync(datei.Path, ct); if (gelesen is null) return false; datei.Container = gelesen.Container; datei.Duration = gelesen.Duration; datei.BitRate = gelesen.BitRate; datei.ProbedAt = laufZeit; await db.SaveChangesAsync(ct); // Die alten Spuren weg, bevor die neuen kommen: eine umkodierte Datei // hat andere, und zwei Sätze nebeneinander ergäben Unsinn. await db.MediaStreams.Where(s => s.MediaFileId == datei.Id).ExecuteDeleteAsync(ct); db.MediaStreams.AddRange(gelesen.Streams.Select(s => new MediaStream { MediaFileId = datei.Id, Index = s.Index, Kind = s.Kind, Codec = s.Codec, Language = s.Language, Title = s.Title, IsDefault = s.IsDefault, IsForced = s.IsForced, Channels = s.Channels, Width = s.Width, Height = s.Height, })); await db.SaveChangesAsync(ct); return true; } /// Löscht Titel, zu denen keine Datei mehr gehört. /// /// /// In dieser Reihenfolge, und das ist keine Geschmacksfrage: erst Folgen /// ohne Datei, dann Serien ohne Folgen. Andersherum bliebe eine Serie /// stehen, deren letzte Folge im selben Lauf verschwindet. /// private async Task VerwaisteAufraeumen(Guid libraryId, CancellationToken ct) { await db.Movies .Where(m => m.LibraryId == libraryId && !db.MediaFiles.Any(f => f.MovieId == m.Id)) .ExecuteDeleteAsync(ct); await db.Episodes .Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == libraryId) && !db.MediaFiles.Any(d => d.EpisodeId == f.Id)) .ExecuteDeleteAsync(ct); await db.Series .Where(s => s.LibraryId == libraryId && !db.Episodes.Any(f => f.SeriesId == s.Id)) .ExecuteDeleteAsync(ct); } /// Sucht den Film — oder legt ihn an. /// /// /// Der Titel **aus dem Dateinamen** und das Jahr entscheiden — nicht der /// angezeigte Titel, der nach dem ersten Lauf von der Metadatenquelle /// kommt und sich dabei ändert. Nur über den Titel ohne Jahr zu gehen /// würfe die beiden „Dune" von 1984 und 2021 zusammen; über die Datei zu /// gehen machte aus 4K- und 1080p-Fassung zwei Filme. /// private Movie FilmFinden(List bekannt, Guid libraryId, MovieName name) { var treffer = bekannt.FirstOrDefault(m => string.Equals(m.ParsedTitle, name.Title, StringComparison.OrdinalIgnoreCase) && m.ParsedYear == name.Year); if (treffer is not null) return treffer; var neu = new Movie { LibraryId = libraryId, ParsedTitle = name.Title, ParsedYear = name.Year, Title = name.Title, SortTitleValue = SortTitle.For(name.Title), Year = name.Year, }; db.Movies.Add(neu); bekannt.Add(neu); return neu; } private Series SerieFinden(List bekannt, Guid libraryId, string titel) { var treffer = bekannt.FirstOrDefault(s => string.Equals(s.ParsedTitle, titel, StringComparison.OrdinalIgnoreCase)); if (treffer is not null) return treffer; var neu = new Series { LibraryId = libraryId, ParsedTitle = titel, Title = titel, SortTitleValue = SortTitle.For(titel), }; db.Series.Add(neu); bekannt.Add(neu); return neu; } private Episode FolgeFinden(List bekannt, Guid serieId, EpisodeName name) { var treffer = bekannt.FirstOrDefault(f => f.SeriesId == serieId && f.SeasonNumber == name.Season && f.EpisodeNumber == name.Episode); if (treffer is not null) { // Ein Folgentitel, der erst später auftaucht, wird nachgetragen — // aber ein vorhandener nie überschrieben: er kann von TMDB kommen // und ist dann besser als alles im Dateinamen. treffer.Title ??= name.EpisodeTitle; return treffer; } var neu = new Episode { SeriesId = serieId, SeasonNumber = name.Season, EpisodeNumber = name.Episode, Title = name.EpisodeTitle, }; db.Episodes.Add(neu); bekannt.Add(neu); return neu; } }