Cover-Suche: Fünf Anbieter parallel abfragen (Teil 26)

Jede Hörspiel-Serie hat ein eigenes Cover — das Serienlogo, das man von CD-Hüllen oder Streaming-Diensten kennt. In EchoPlay wird dieses Cover in der lokalen Bibliothek angezeigt, also beim Blättern durch die eigene Sammlung. Die Herausforderung dabei: Das Cover muss aus dem Dateisystem geholt werden, nicht aus den ID3-Tags der einzelnen Audiodateien. Warum das so ist, wie die Suchreihenfolge funktioniert und wie fünf Online-Anbieter parallel abgefragt werden — darum geht es in diesem Artikel.

Warum nicht aus den ID3-Tags?

Audiodateien können Cover direkt in ihrem ID3-Tag eingebettet haben. Der erste Gedanke wäre, dieses Cover einfach zu lesen. Das führt aber zu einem typischen Problem bei Hörspielen: Die ID3-Tags einzelner Tracks enthalten oft das Episoden-Cover, nicht das Serien-Cover. Beim Hörspiel „Die drei Fragezeichen — Folge 1: Der Super-Papagei“ trägt jeder Track das Cover dieser konkreten Folge. Das Serienlogo, also das allgemeine „Die drei ???“-Bild, steckt dagegen typischerweise als Datei im Serienordner. Würde man das ID3-Cover nehmen, würde in der Serienübersicht je nach zufällig gelesenem Track das Cover einer bestimmten Folge erscheinen — statt des einheitlichen Serien-Logos.

Suchreihenfolge: lokal vor online

Der LocalCoverService sucht Cover nach einer festen Prioritätsreihenfolge. Das Ergebnis der ersten Fundstelle wird sofort zurückgegeben, die weiteren werden übersprungen. Zuerst wird in einem Cover-Unterordner gesucht, dann nach bekannten Dateinamen im Serienordner, danach nach Windows-Media-Player-Dateien (AlbumArt_*_Large.jpg) und zuletzt wird ein Download von einer URL versucht.

Cover-Unterordner

Manche Sammlungen legen Cover in einem dedizierten Unterordner ab, zum Beispiel Covers oder Artwork. Der Service sucht nach solchen Ordnern:

private static readonly string[] CoverSubfolderNames =
[
    "Cover", "Covers", "cover", "covers", "Artwork", "artwork"
];

Innerhalb dieses Ordners gelten besondere Regeln: Dateien, deren Name „back“ enthält, werden übersprungen — das ist die Rückseite der CD-Hülle, nicht das Cover. Dateien mit „front“ im Namen werden bevorzugt. Alle anderen .jpg– und .jpeg-Dateien kommen als Fallback in Frage.

// Rückseite ausschließen, Vorderseite bevorzugen
bool isBack   = name.Contains("back",  StringComparison.OrdinalIgnoreCase);
bool isFront  = name.Contains("front", StringComparison.OrdinalIgnoreCase);

Bekannte Dateinamen

Gängige Cover-Dateinamen werden der Reihe nach geprüft:

private static readonly string[] CoverFileNames =
[
    "cover.jpg", "cover.jpeg", "folder.jpg", "folder.jpeg", "Folder.jpg", "album.jpg"
];

folder.jpg ist der klassische Name, den Windows Explorer früher für Ordner-Thumbnails verwendet hat. cover.jpg ist heute die verbreitetste Konvention. Die verschiedenen Schreibweisen decken unterschiedliche Betriebssystem- und Ripping-Software-Konventionen ab.

Windows Media Player: AlbumArt

Ältere Windows-Media-Player-Installationen legten Cover automatisch als AlbumArt_{GUID}_Large.jpg in den Ordner. Da der GUID-Teil nicht vorhersagbar ist, wird ein Wildcard-Pattern verwendet:

private const string AlbumArtLargePattern = "AlbumArt_*_Large.jpg";

string[] matches = Directory.GetFiles(folderPath, AlbumArtLargePattern);

Directory.GetFiles mit einem Wildcard-Pattern ist die einfachste Methode für diesen Fall — kein regulärer Ausdruck nötig.

Cover-Download aus URL

Wenn lokal nichts gefunden wird, kann das Cover von einer URL heruntergeladen werden. Die URL kommt typischerweise aus den Streaming-Daten — Spotify-Episoden enthalten Cover-URLs. Das heruntergeladene Bild wird als cover.jpg im Serienordner gespeichert. Beim nächsten Sync-Durchlauf greift dann automatisch die lokale Suche.

Rückgabewert: byte[] oder null

Der Service gibt die Rohdaten des Bildes als byte[] zurück. null bedeutet „kein Cover gefunden“. Der Aufrufer entscheidet, wie er damit umgeht — in der Regel wird aus byte[] ein BitmapImage für die UI erzeugt:

byte[]? coverData = await _localCoverService.GetCoverAsync(seriesFolder, coverUrl);
if (coverData is not null)
{
    using MemoryStream stream = new(coverData);
    BitmapImage bitmap = new();
    await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
    CoverImage = bitmap;
}

Dieses Muster trennt Datenzugriff und UI-Konvertierung sauber voneinander. Der Service weiß nichts von BitmapImage, das ViewModel nichts von Dateisystemzugriffen.

Testen ohne echtes Dateisystem

ILocalCoverService ist ein Interface. Im Test kommt ein FakeLocalCoverService zum Einsatz, der immer null zurückgibt oder ein vorher festgelegtes byte[]. So können Sync-Tests prüfen, ob der Service korrekt aufgerufen wird, ohne auf echte Bilddateien angewiesen zu sein. Die echten LocalCoverService-Tests in EchoPlay.LocalLibrary.Tests arbeiten dagegen mit temporären Verzeichnissen und echten Testdateien — das stellt sicher, dass die Dateinamen-Logik und Ordner-Suche tatsächlich funktionieren.

Manuelles Cover-Ändern und Online-Suche

Neben dem automatischen Einlesen beim Scan können Nutzer Cover auch manuell ersetzen. Das ist dann sinnvoll, wenn das lokale Cover nicht passt, fehlt oder durch ein besseres Bild aus dem Internet ersetzt werden soll. Beide Wege sind über das ...-Kontextmenü in der Serien- und Folgen-Kachelansicht erreichbar.

Warum Cover in der Datenbank speichern?

Lokal ausgewählte oder heruntergeladene Cover werden als byte[] in der separaten CoverImages-Tabelle gespeichert. Diese Tabelle enthält EntityType (z.B. „Series“ oder „Episode“), EntityId, ImageData, SourceUrl und LastChecked. Damit bleibt das Cover unabhängig davon, ob die Datei auf der Festplatte noch existiert oder umbenannt wurde. Die alten Properties Series.LocalCoverData und Episode.LocalCoverData existieren noch auf den Entitäten, sind aber veraltet — CoverImages ist die alleinige Quelle der Wahrheit.

Zum Laden und Speichern von Covern gibt es den CoverService in der App-Schicht, als Singleton registriert. Er kapselt den Zugriff auf die CoverImages-Tabelle und stellt Methoden wie GetEpisodeCoverImageAsync() und GetSeriesCoverImageAsync() bereit:

BitmapImage? coverImage = await _coverService.GetEpisodeCoverImageAsync(episode.Id);

if (coverImage is null)
{
    // Fallback: cover.jpg oder ID3-Tag
    byte[]? coverBytes = await _coverLoader.LoadAsync(episode.LocalFolderPath, firstTrackPath);
    coverImage = coverBytes is not null ? await BitmapImageFromBytesAsync(coverBytes) : null;
}

Ordner-Trennregel: Serien-Cover bleibt im Serienordner

Ein wichtiges Detail: cover.jpg im Serienordner gehört der Serie. cover.jpg in einem Folgen-Unterordner gehört der Folge. Diese Trennung wird absichtlich eingehalten — ILocalCoverLoader verwendet SearchOption.TopDirectoryOnly und schaut nie in Unterordner. Serien-Cover und Folgen-Cover sind eigenständige Dinge.

Online-Suche über fünf Anbieter

Wenn kein passendes lokales Bild vorhanden ist, kann der Nutzer über „Cover suchen“ eine Online-Suche starten. EchoPlay fragt dabei fünf kostenlose Dienste parallel ab — keiner davon benötigt einen API-Key. Deezer Artists liefert Künstler-Profilbilder und eignet sich besonders gut für Serien-Cover. Cover Art Archive greift auf die MusicBrainz-Datenbank zu und liefert Album-Cover. iTunes Search bringt Album-Artwork aus dem Apple-Katalog. Deezer Albums steuert Album-Cover aus dem Deezer-Katalog bei. Und Discogs liefert Release-Scans von CDs, Kassetten und Vinyl — besonders wertvoll für ältere Hörspiele.

Deezer-Künstlerbilder sind besonders wertvoll für Serien-Cover, weil die anderen Dienste nur Album-Cover liefern — also Bilder einzelner Folgen, nicht das Serienlogo.

Composite-Pattern: alle Anbieter hinter einem Interface

Jeder Anbieter implementiert das gleiche Interface ICoverSearchService. Ein CompositeCoverSearchService fasst alle zusammen und ruft sie parallel über Task.WhenAll ab:

public sealed class CompositeCoverSearchService : ICoverSearchService
{
    private readonly IReadOnlyList<ICoverSearchService> _providers;

    public async Task<IReadOnlyList<CoverSearchResult>> SearchAsync(
        string title, CancellationToken ct = default)
    {
        // Alle Anbieter gleichzeitig abfragen
        Task<IReadOnlyList<CoverSearchResult>>[] tasks =
            new Task<IReadOnlyList<CoverSearchResult>>[_providers.Count];

        for (int i = 0; i < _providers.Count; i++)
        {
            tasks[i] = SafeSearchAsync(_providers[i], title, ct);
        }

        IReadOnlyList<CoverSearchResult>[] allResults = await Task.WhenAll(tasks);

        // Ergebnisse zusammenführen
        List<CoverSearchResult> combined = [];
        foreach (IReadOnlyList<CoverSearchResult> providerResults in allResults)
        {
            combined.AddRange(providerResults);
        }
        return combined;
    }
}

Ein fehlender Anbieter (Netzwerkfehler, Timeout) liefert eine leere Liste statt die gesamte Suche abzubrechen. Das SafeSearchAsync-Wrapper-Pattern fängt Exceptions einzelner Anbieter ab.

Die Registrierung im DI-Container bestimmt die Reihenfolge der Ergebnisse — Künstlerbilder zuerst, dann Album-Cover:

services.AddTransient<ICoverSearchService>(provider =>
{
    DeezerArtistCoverSearchService deezerArtists = provider.GetRequiredService<DeezerArtistCoverSearchService>();
    CoverArtArchiveSearchService musicBrainz     = provider.GetRequiredService<CoverArtArchiveSearchService>();
    ITunesCoverSearchService iTunes               = provider.GetRequiredService<ITunesCoverSearchService>();
    DeezerAlbumCoverSearchService deezerAlbums    = provider.GetRequiredService<DeezerAlbumCoverSearchService>();
    DiscogsCoverSearchService discogs             = provider.GetRequiredService<DiscogsCoverSearchService>();

    return new CompositeCoverSearchService([deezerArtists, musicBrainz, iTunes, deezerAlbums, discogs]);
});

User-Agent-Pflicht bei MusicBrainz und Discogs

Zwei der Anbieter verlangen einen aussagekräftigen User-Agent-Header — ohne ihn werden Anfragen gedrosselt oder abgelehnt:

services.AddHttpClient<CoverArtArchiveSearchService>(client =>
{
    client.DefaultRequestHeaders.UserAgent.ParseAdd("EchoPlay/1.0 (https://ruhrcoder.de)");
});

Deezer und iTunes akzeptieren Anfragen auch ohne speziellen Header.

ContentDialog statt GridView

Der Auswahldialog wird als ContentDialog im Code-Behind aufgebaut. Die Cover-Kacheln werden in einem VariableSizedWrapGrid angezeigt — nicht in einem GridView. Der Grund: WinUI 3 wirft eine COMException wenn ein GridView innerhalb eines ContentDialog seine Items nach dem Öffnen modifiziert. Das VariableSizedWrapGrid mit manueller Klick-Logik umgeht dieses Problem.

Die Auswahl funktioniert über PointerPressed-Events auf Border-Elementen. Bei Klick wird der Rahmen der gewählten Kachel auf die Akzentfarbe gesetzt, alle anderen werden zurückgesetzt:

tileBorder.PointerPressed += (_, _) =>
{
    // Vorherige Auswahl zurücksetzen
    foreach (UIElement child in resultsPanel.Children)
    {
        if (child is Border b)
            b.BorderBrush = new SolidColorBrush(Colors.Transparent);
    }
    tileBorder.BorderBrush = (Brush)Application.Current.Resources["AccentFillColorDefaultBrush"];
    selectedIndex = tileIndex;
    dialog.IsPrimaryButtonEnabled = true;
};

Delegate statt Event für die Suche

Das ViewModel stellt eine SearchCoversAsync-Methode bereit. Die Page übergibt sie als Delegate an den Dialog:

CoverSearchResult? selected = await ShowCoverSearchDialogAsync(
    card.Title,
    (query, ct) => ViewModel.SearchCoversAsync(query, ct));

Dieses Muster ist einfacher als der frühere Event-basierte Ansatz: kein _pendingSeriesCoverCard-Feld nötig, kein Event-Abonnement. Der Dialog bekommt die Suchfunktion direkt und gibt das Ergebnis zurück.

Bestätigung vor dem Überschreiben

Hat eine Serie oder Folge bereits ein Cover, fragt das ViewModel den Nutzer per IConfirmationDialogService um Erlaubnis, bevor die Bytes geschrieben werden. Dieses Interface ist in Tests durch FakeConfirmationDialogService ersetzbar, der immer true oder immer false zurückgeben kann.

Neu-Initialisierung löscht gespeicherte Cover

Beim vollständigen Reset der lokalen Bibliothek werden die zugehörigen Einträge in der CoverImages-Tabelle gelöscht. Zusätzlich werden die veralteten LocalCoverData-Properties auf den Entitäten auf null gesetzt. Ohne diesen Schritt würde ein altes, manuell gesetztes Cover dauerhaft in der Datenbank verbleiben, selbst wenn der Nutzer nach dem Reset andere Bilder auf die Festplatte legt.

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