Cover-Architektur: Blobs von Metadaten trennen (Teil 30)

Cover-Bilder können bis zu 5 MB pro Stück groß sein. In einer frühen Version von EchoPlay lagen diese Binärdaten direkt in den Tabellen Series und Episodes. Das Problem: Jede Query auf Titel, Folgennummer oder Status lud potenziell Megabytes an Bilddaten mit — auch wenn sie gar nicht gebraucht wurden. Bei 500+ Episoden wurde das spürbar langsam.

Lösung: Eigene CoverImages-Tabelle

Binärdaten gehören nicht in Metadaten-Tabellen. Die neue Architektur trennt sauber: Die CoverImages-Tabelle speichert ausschließlich Bilddaten, zusammen mit einem EntityType (z.B. „Series“ oder „Episode“) und einer EntityId. Dazu kommt eine SourceUrl als Fallback und ein LastChecked-Zeitstempel für den Background-Worker.

Die Tabelle hat keinen echten Foreign Key auf Series oder Episodes, sondern ein EntityType + EntityId-Paar mit Unique-Index. Das ermöglicht eine Tabelle für beide Entity-Typen ohne polymorphe Beziehung. Zwei Indizes optimieren die Abfragen: (EntityType, EntityId) als Unique-Index für Einzelzugriffe und (EntityType, LastChecked) für Background-Worker-Queries.

Drei-Schichten-Zugriff

Der Zugriff auf Cover folgt dem bekannten Schichtenmuster. In der Data-Schicht gibt es den ICoverImageDataService mit reinen DB-Operationen — keine UI-Logik:

Task<CoverImage?> GetByEntityAsync(string entityType, Guid entityId);
Task<IReadOnlyDictionary<Guid, byte[]>> GetImageDataByEntitiesAsync(
    string entityType, IReadOnlyList<Guid> entityIds);  // Batch!
Task SetCoverAsync(string entityType, Guid entityId, byte[] imageData, string? sourceUrl);
Task<bool> ExistsAsync(string entityType, Guid entityId);  // Ohne Blob

In der App-Schicht sitzt der CoverService als zentrale Anlaufstelle für alle ViewModels. Er kapselt die IServiceScopeFactory und die Bitmap-Konvertierung:

Task<BitmapImage?> GetSeriesCoverImageAsync(Guid seriesId);
Task<IReadOnlyDictionary<Guid, byte[]>> GetEpisodeCoverBytesAsync(IReadOnlyList<Guid> ids);
Task SetEpisodeCoverAsync(Guid episodeId, byte[] imageData, string? sourceUrl);
static Task<BitmapImage?> ConvertToBitmapAsync(byte[] imageData);

Daneben gibt es den BackgroundCoverService — ein automatischer Hintergrund-Task, der alle 30 Minuten läuft. Er entfernt falsche Cover von reinen Online-Episoden, liest cover.jpg und ID3-Tags aus lokalen Episoden-Ordnern und speichert die Ergebnisse in der CoverImages-Tabelle.

CoverCopyService: SQL-basierte Cover-Kopie

Wenn eine Serie sowohl lokal als auch online existiert (gleicher Titel), werden lokale Episoden-Cover auf Online-Episoden kopiert — komplett in SQL:

INSERT OR IGNORE INTO CoverImages (Id, EntityType, EntityId, ImageData, ...)
SELECT ..., 'Episode', tgt.Id, ci.ImageData, ...
FROM Episodes tgt
INNER JOIN Episodes src ON src.EpisodeNumber = tgt.EpisodeNumber ...
INNER JOIN CoverImages ci ON ci.EntityType = 'Episode' AND ci.EntityId = src.Id
WHERE tgt.SeriesId = @targetSeriesId
  AND NOT EXISTS (SELECT 1 FROM CoverImages x WHERE x.EntityId = tgt.Id)

Kein Blob wird nach C# geladen. Die gesamte Kopie läuft in der Datenbank. INSERT OR IGNORE statt einem vorherigen EXISTS-Check macht die Operation Race-Condition-sicher — falls zwei Threads gleichzeitig versuchen, ein Cover zu schreiben, gewinnt der erste und der zweite wird ignoriert.

Ablauf beim Öffnen einer Online-Serie

Wenn der Nutzer eine Online-Serie öffnet, passiert im Hintergrund ein dreistufiger Prozess. Zuerst werden lokale Cover aus cover.jpg und ID3-Tags in die CoverImages-Tabelle geschrieben. Dann kopiert der CoverCopyService per SQL lokale Cover auf die passenden Online-Episoden. Zum Schluss lädt der CoverService die Cover per Batch-Query aus der Datenbank und stellt sie in der UI dar. Durch die Trennung von Blob und Metadaten sind diese Queries jetzt schnell — egal wie groß die Cover sind.

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