Files
Flaro/server/src/Flaro.Data/LibraryScanner.cs
T
ScarriffleandClaude Opus 5 a7cf6c5def Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 19:38:27 +02:00

294 lines
11 KiB
C#

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<WalkProblem> Problems);
/// <summary>Gleicht ab, was auf der Platte liegt, mit dem, was in der Datenbank steht.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Scan ist beliebig oft wiederholbar.</b> 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.
/// </para>
/// <para>
/// <b>Weggeräumt wird über den Zeitstempel.</b> 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.
/// </para>
/// </remarks>
public sealed class LibraryScanner(
FlaroContext db, IMediaProbe sonde, TimeProvider clock, ILogger<LibraryScanner> log)
{
public async Task<ScanReport> 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);
}
/// <summary>Liest die Spuren einer Datei und schreibt sie fort.</summary>
///
/// <remarks>
/// <b>Erst speichern, dann untersuchen.</b> 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 <c>ProbedAt</c> leer und der nächste
/// Lauf versucht es erneut. Eine Datei, die gerade noch geschrieben wird,
/// ist beim nächsten Mal vielleicht lesbar.
/// </remarks>
private async Task<bool> 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;
}
/// <summary>Löscht Titel, zu denen keine Datei mehr gehört.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>Sucht den Film — oder legt ihn an.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
private Movie FilmFinden(List<Movie> 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<Series> 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<Episode> 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;
}
}