SyncService: IServiceScopeFactory in Singleton-Services (Teil 14)

EchoPlay kann Hörspiele nicht nur aus Spotify importieren, sondern auch lokale MP3-Sammlungen verwalten. Der SyncService ist dafür zuständig: Er scannt einen Ordner auf der Festplatte, erkennt Serien und Episoden anhand von Ordnernamen und Dateinummern und verknüpft die lokalen Dateipfade mit den bereits in der Datenbank gespeicherten Einträgen. Das Ergebnis ist eine saubere Brücke zwischen zwei Welten — der Online-Datenbank und dem lokalen Dateisystem.

Das Grundprinzip

Der Sync-Vorgang ist eine Abgleichoperation zwischen zwei Datenquellen. Auf der einen Seite stehen die Serien und Episoden in der Datenbank, die über Spotify importiert wurden. Auf der anderen Seite liegen Ordner mit MP3-Dateien, deren Namen nach einem bestimmten Muster aufgebaut sind. Das Ziel: Jedem Datenbankeintrag wird ein lokaler Dateipfad zugewiesen, und jeder Episode ein passender lokaler Ordnerpfad. So weiß die App, welche Hörspiele auch offline verfügbar sind.

IServiceScopeFactory: Singleton mit Scoped-Services

Der SyncService ist als Singleton registriert — er existiert also genau einmal für die gesamte Lebensdauer der App. Er braucht aber Scoped-Services wie den ISeriesDataService, der intern einen DbContext verwendet. Würde der Singleton den Scoped-Service direkt per Konstruktor-Injection erhalten, hätte man ein sogenanntes Captive-Dependency-Problem: Der DbContext würde nie freigegeben, weil der Singleton selbst nie freigegeben wird. Die Lösung ist IServiceScopeFactory.

public sealed class SyncService : ISyncService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public async Task<SyncResult> SyncAsync(IProgress<string>? progress = null)
    {
        using IServiceScope scope = _scopeFactory.CreateScope();

        IAppSettingsDataService settingsService = scope.ServiceProvider.GetRequiredService<IAppSettingsDataService>();
        ISeriesDataService seriesService        = scope.ServiceProvider.GetRequiredService<ISeriesDataService>();
        IEpisodeDataService episodeService      = scope.ServiceProvider.GetRequiredService<IEpisodeDataService>();
        // ... weitere Services

        // Alle Datenbankoperationen laufen über den scope-lokalen DbContext
    } // scope.Dispose() – DbContext wird freigegeben
}

Für jede Ausführung von SyncAsync wird ein frischer Scope erstellt. Alle Services innerhalb dieses Scopes teilen sich denselben DbContext. Das garantiert Konsistenz innerhalb eines Sync-Vorgangs — und sobald der using-Block endet, wird der Scope mitsamt dem DbContext sauber freigegeben.

IProgress: Fortschritts-Callback

Während der Sync läuft, möchte die Benutzeroberfläche dem Nutzer mitteilen, was gerade passiert. Dafür nutzt der SyncService das .NET-Standard-Interface IProgress<string>. Es funktioniert wie ein Callback: Der Aufrufer gibt ein Progress<T>-Objekt mit, und der Service ruft bei jedem Fortschrittsschritt Report auf.

public async Task<SyncResult> SyncAsync(IProgress<string>? progress = null)
{
    progress?.Report("Scannen der lokalen Bibliothek …");

    IReadOnlyList<LocalScanResult> scanResults = await scanner.ScanSeriesAsync(...);

    progress?.Report($"{scanResults.Count} Serienordner gefunden.");

    foreach (LocalScanResult scanResult in scanResults)
    {
        progress?.Report($"Synchronisiere "{matchedSeries.Title}" …");
        // ...
    }
}

In der UI sieht die Verwendung dann so aus:

Progress<string> progress = new(message => StatusText = message);
await _syncService.SyncAsync(progress);

Das Progress<T>-Objekt stellt sicher, dass der Callback auf dem UI-Thread ausgeführt wird — vorausgesetzt, es wurde auf dem UI-Thread erstellt. Kein explizites Dispatching nötig. Der ?.-Operator macht den progress-Parameter optional: Wenn kein Fortschritts-Callback gewünscht ist, wird null übergeben, und progress?.Report(...) wird einfach übersprungen.

4-Phasen-Scan mit IScanOrchestrator

Ein einfacher Fortschrittstext reicht für kurze Vorgänge aus. Bei einem vollständigen Scan einer lokalen Bibliothek mit vielen Serien möchte der Nutzer aber genauer wissen, in welcher Phase der Vorgang gerade steckt. Wird gerade gescannt? Werden Metadaten gelesen? Wird das Cover geladen? Werden die Datenbankeinträge aktualisiert? Dafür gibt es den IScanOrchestrator. Er teilt den Scan in vier klar benannte Phasen auf und meldet den Fortschritt strukturiert über ein eigenes Modell.

public sealed class ScanProgress
{
    public int    Phase       { get; init; } // 1–4
    public string PhaseLabel  { get; init; } // z.B. "Bibliothek scannen"
    public string Message     { get; init; } // detaillierter Status
    public int    Processed   { get; init; } // bereits verarbeitete Einheiten
    public int    Total       { get; init; } // Gesamtanzahl
}

Die vier Phasen sind in ScanPhaseLabels als Konstanten definiert:

public static class ScanPhaseLabels
{
    public const string Scanning  = "Bibliothek scannen";
    public const string Covers    = "Cover laden";
    public const string Importing = "Importieren";
    public const string Syncing   = "Synchronisieren";
}

Der Vorteil dieses Ansatzes: Die UI muss nicht selbst zählen oder Phasen ableiten. Sie empfängt ein vollständiges ScanProgress-Objekt und kann daraus direkt Phasennummer, beschrifteten Fortschrittsbalken und Statustext befüllen. Und warum ein eigener Orchestrator statt den Scanner direkt aufzurufen? Der SyncService ist ein Singleton und ruft den Scanner in einem eigenen IServiceScope auf. Den Orchestrator als eigene, scope-lebende Komponente zu gestalten, macht ihn im Test ersetzbar — Tests können einen FakeScanOrchestrator einsetzen, der den echten Scanner-Aufruf kapselt, ohne echte Dateisystem-Zugriffe zu benötigen.

Fuzzy-Matching mit HoerspielTextNormalizer

Serienordner heißen oft anders als die Serientitel in der Datenbank. Ein Ordner könnte Die_drei_Fragezeichen heißen, während die Datenbank Die drei ??? gespeichert hat. Ein exakter String-Vergleich würde hier scheitern. HoerspielTextNormalizer.Normalize normalisiert beide Strings auf eine vergleichbare Form: Groß-/Kleinschreibung wird ignoriert, Sonderzeichen werden entfernt, Fragezeichen und Ausrufezeichen werden normalisiert.

private static Series? FindMatchingSeries(IReadOnlyList<Series> series, string folderName)
{
    string normalizedFolder = HoerspielTextNormalizer.Normalize(folderName);

    foreach (Series s in series)
    {
        if (HoerspielTextNormalizer.Normalize(s.Title) == normalizedFolder)
        {
            return s;
        }
    }

    return null;
}

Das ist kein Fuzzy-Match im klassischen Sinne — keine Levenshtein-Distanz oder ähnliche Algorithmen. Es ist eine Normalisierung auf Basis von Heuristiken für Hörspiel-Namenskonventionen. Underscores werden zu Leerzeichen, Sonderzeichen wie ??? werden zu fragezeichen, alles wird kleingeschrieben. Für die überwiegende Mehrheit der Fälle reicht das aus.

Episodennummer-Matching

Innerhalb einer Serie wird jede Episode einer lokalen Datei zugeordnet. Die Episodennummer wird aus dem Ordnernamen geparst — und dann mit den Nummern in der Datenbank abgeglichen.

foreach (LocalEpisodeScan episodeScan in scanResult.Episodes)
{
    if (episodeScan.ParsedNumber is null)
    {
        continue; // Ordner ohne erkennbare Nummer überspringen
    }

    Episode? episode = FindEpisodeByNumber(episodes, episodeScan.ParsedNumber.Value);

    if (episode is null)
    {
        continue; // Keine passende DB-Episode – ggf. noch nicht importiert
    }

    episode.LocalFolderPath = episodeScan.FolderPath;
    episode.LocalTrackCount = episodeScan.TrackCount;
    episode.TrackMatchKind  = trackMatcher.Classify(episodeScan.TrackCount, onlineTrackCount);

    await episodeService.UpdateAsync(episode);
}

TrackMatchKind klassifiziert den Match: Stimmt die Anzahl der lokalen MP3-Dateien mit der erwarteten Anzahl aus der Online-Quelle überein? Das hilft dem Nutzer zu erkennen, ob eine Episode vollständig oder unvollständig heruntergeladen wurde. Wichtig zu wissen: TrackMatchKind lebt in EchoPlay.Core.Models, nicht in EchoPlay.Data.Entities.Library. Das ist bewusst so — die Klassifikationslogik ist ein fachliches Konzept und gehört in die Core-Schicht. EchoPlay.LocalLibrary referenziert nur EchoPlay.Core, nicht EchoPlay.Data. Die Persistierung der Ergebnisse übernimmt die App-Schicht.

LocalTracks: Metadaten aus MP3-Dateien

Für jede MP3-Datei einer Episode werden Metadaten gelesen — die Dauer und die Tracknummer aus dem ID3-Tag. Das ist der Metadaten-Block, den jede MP3-Datei in ihrem Header mitbringt.

try
{
    (TimeSpan readDuration, int readTrackNumber) = metadataReader.Read(path);
    duration = readDuration;

    // Tracknummer aus ID3-Tag bevorzugen; fehlt sie, Dateireihenfolge nutzen
    if (readTrackNumber > 0)
    {
        trackNumber = readTrackNumber;
    }
}
catch (Exception ex)
{
    // Beschädigte Datei – Standardwerte verwenden, Sync nicht abbrechen
    _logger.Warning($"Metadaten nicht lesbar: {path} ({ex.Message})");
}

Wichtig: Ein Fehler bei einer einzelnen Datei bricht den gesamten Sync nicht ab. Der Fehler wird geloggt, Standardwerte werden verwendet (Dauer gleich null, Tracknummer gleich Dateireihenfolge), und der Sync läuft weiter. Das ist ein bewusstes Design — eine beschädigte MP3-Datei darf nicht den kompletten Bibliotheks-Scan sabotieren.

Idempotenz

Der Sync ist idempotent: Mehrfaches Ausführen liefert dasselbe Ergebnis. Das bedeutet: Wenn LocalFolderPath bereits gesetzt ist und derselbe Sync nochmals läuft, wird derselbe Wert nochmals gespeichert. Nichts wird doppelt angelegt, keine Duplikate entstehen. SaveTracksForEpisodeAsync löscht bestehende Tracks für eine Episode und legt sie neu an — das ist eine saubere Upsert-Semantik für die LocalTrack-Liste. Egal wie oft du den Sync startest, das Ergebnis ist immer konsistent.

SyncResult: Zusammenfassung

SyncResult result = new()
{
    SeriesMatched    = seriesMatched,
    SeriesUnmatched  = seriesUnmatched,
    EpisodesUpdated  = episodesUpdated,
    TracksCreated    = tracksCreated
};

_logger.Info($"Sync abgeschlossen: {result}");

Das SyncResult-Objekt gibt dem Aufrufer eine kompakte Zusammenfassung: Wie viele Serien wurden gefunden? Wie viele konnten nicht zugeordnet werden? Wie viele Episoden wurden aktualisiert, wie viele Tracks angelegt? Die UI zeigt diese Zahlen nach dem Sync in einer übersichtlichen Statusmeldung an — so weiß der Nutzer sofort, ob alles geklappt hat oder ob Ordner unerkannt geblieben sind.

Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu