Import-Flow: ImportService und Keyed Services (Teil 15)

Wenn ein Nutzer nach einer Hörspielserie sucht und sie importiert, durchläuft die Anfrage mehrere Schichten: die UI, den ImportService, den richtigen Provider und schließlich die Datenbank. Der gesamte Ablauf zeigt, wie Dependency Injection und Keyed Services dabei helfen, zwei Provider — Spotify und Apple Music — austauschbar zu machen, ohne eine einzige Zeile Code ändern zu müssen.

Der ImportService: Koordinator

Der ImportService ist der zentrale Koordinator des gesamten Import-Vorgangs. Er weiß nichts über Spotify oder Apple Music direkt — er kennt nur Interfaces. Welcher Provider aktiv ist, wird aus den AppSettings in der Datenbank gelesen. Ohne eine Zeile Code zu ändern, kann der Nutzer in den Einstellungen zwischen Spotify und Apple Music wechseln.

public sealed class ImportService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public async Task<IReadOnlyList<ImportSeries>> SearchAsync(string query)
    {
        if (string.IsNullOrWhiteSpace(query))
        {
            throw new ArgumentException("Suchbegriff darf nicht leer sein.", nameof(query));
        }

        using IServiceScope scope = _scopeFactory.CreateScope();

        IAppSettingsDataService settingsService = scope.ServiceProvider.GetRequiredService<IAppSettingsDataService>();
        AppSettings settings = await settingsService.GetAsync();

        // Provider aus AppSettings laden – kein hartcodierter Typ
        string providerKey = settings.ActiveProvider.ToString(); // "Spotify" oder "AppleMusic"
        ISeriesImportSearch search = scope.ServiceProvider
            .GetRequiredKeyedService<ISeriesImportSearch>(providerKey);

        return await search.SearchAsync(query);
    }
}

Der providerKey ist ein einfacher String — "Spotify" oder "AppleMusic". Er wird aus der Datenbank gelesen, nicht im Code festgelegt. Das macht den Provider-Wechsel zu einer reinen Konfigurationssache.

Keyed Services: Zwei Provider, ein Interface

Keyed Services wurden in .NET 8 eingeführt. Sie lösen ein häufiges Problem in der Dependency Injection: Was tun, wenn man mehrere Implementierungen desselben Interfaces registrieren will? Normalerweise kann der DI-Container nur eine Implementierung pro Interface auflösen. Keyed Services fügen einen String-Schlüssel hinzu, über den die richtige Implementierung ausgewählt wird.

// In SpotifyServiceCollectionExtensions.AddSpotifyImport():
services.AddKeyedScoped<ISeriesImportSearch, SpotifySeriesImportSearch>("Spotify");
services.AddKeyedScoped<IEpisodeImportSource, SpotifyEpisodeImportSource>("Spotify");

// In AppleMusicServiceCollectionExtensions.AddAppleMusicImport():
services.AddKeyedScoped<ISeriesImportSearch, AppleMusicSeriesImportSearch>("AppleMusic");
services.AddKeyedScoped<IEpisodeImportSource, AppleMusicEpisodeImportSource>("AppleMusic");

Die Auflösung mit dem Key sieht dann so aus:

ISeriesImportSearch search = scope.ServiceProvider
    .GetRequiredKeyedService<ISeriesImportSearch>(providerKey);

Ohne Keyed Services müsste man eine Factory, ein Dictionary oder eine Switch-Anweisung schreiben. Keyed Services halten das elegant im DI-Container — der Code, der den Service nutzt, bleibt schlank und muss sich nicht um die Auswahl kümmern.

ImportSeries: Das neutrale Modell

Egal ob die Suchergebnisse von Spotify oder Apple Music kommen — der ImportService arbeitet immer mit ImportSeries. Das ist ein neutrales Datenmodell ohne provider-spezifische Felder. Ein DTO-artiges Objekt, das nur die Informationen enthält, die EchoPlay intern braucht.

public class ImportSeries
{
    public string SourceSeriesId { get; set; } = string.Empty; // Spotify Artist-ID / Apple Music ID
    public string Source { get; set; } = string.Empty;         // "Spotify" oder "AppleMusic"
    public string Title { get; set; } = string.Empty;
    public string? Description { get; set; }
    public string? CoverImageUrl { get; set; }
    public bool IsHoerspiel { get; set; }
    public double Score { get; set; }
}

Der SpotifySeriesMapper übersetzt SpotifyArtistDto in ImportSeries. Der AppleMusicSeriesMapper übersetzt die entsprechenden Apple-Music-Daten in dieselbe Struktur. Die restliche Anwendung kennt keine Spotify- oder Apple-Music-spezifischen Typen — nur ImportSeries.

Duplikate verhindern

Was passiert, wenn eine Serie schon importiert wurde? Ein zweiter Import darf keine Duplikate anlegen. Der ImportService prüft deshalb vor jedem Import, ob die Serie bereits in der Datenbank existiert — und zwar über die externe Provider-ID, nicht über den Titel.

public async Task<Guid> ImportAsync(ImportSeries importSeries)
{
    using IServiceScope scope = _scopeFactory.CreateScope();
    ISeriesDataService seriesService = scope.ServiceProvider.GetRequiredService<ISeriesDataService>();

    // Frühes Abbrechen bei Duplikat
    Series? existing = await FindExistingSeriesAsync(seriesService, importSeries);

    if (existing is not null)
    {
        _logger.Debug($"Import übersprungen – bereits vorhanden: "{importSeries.Title}"");
        return existing.Id;
    }

    // Nicht vorhanden → anlegen
    Series series = MapToSeries(importSeries);
    await seriesService.AddAsync(series);

    // Episoden laden und anlegen
    IEpisodeImportSource episodeSource = scope.ServiceProvider
        .GetRequiredKeyedService<IEpisodeImportSource>(importSeries.Source);
    IReadOnlyList<ImportEpisode> episodes = await episodeSource.GetEpisodesAsync(importSeries.SourceSeriesId);

    foreach (ImportEpisode importEpisode in episodes)
    {
        await episodeService.AddAsync(MapToEpisode(importEpisode, series.Id));
    }

    return series.Id;
}

private static async Task<Series?> FindExistingSeriesAsync(ISeriesDataService service, ImportSeries series)
{
    return series.Source switch
    {
        "Spotify"    => await service.GetBySpotifyArtistIdAsync(series.SourceSeriesId),
        "AppleMusic" => await service.GetByAppleMusicArtistIdAsync(series.SourceSeriesId),
        _            => null
    };
}

Die Duplikatprüfung erfolgt über die externe ID (SpotifyArtistId oder AppleMusicArtistId). Zwei Serien mit demselben Titel aber unterschiedlichen Provider-IDs wären verschiedene Einträge — das ist korrekt, weil es verschiedene Quellen sind. Ein Pattern-Match (switch-Ausdruck) wählt die richtige Suchmethode basierend auf dem Provider.

MapToSeries: Provider-spezifische ID richtig zuordnen

private static Series MapToSeries(ImportSeries importSeries)
{
    return new Series
    {
        Title              = importSeries.Title,
        Description        = importSeries.Description,
        CoverImageUrl      = importSeries.CoverImageUrl,
        SpotifyArtistId    = importSeries.Source == "Spotify"    ? importSeries.SourceSeriesId : null,
        AppleMusicArtistId = importSeries.Source == "AppleMusic" ? importSeries.SourceSeriesId : null,
        IsSubscribed       = true   // Import und Abonnement sind in EchoPlay dasselbe Konzept
    };
}

Eine Series-Entity hat beide Felder: SpotifyArtistId und AppleMusicArtistId. Beim Import von Spotify wird nur SpotifyArtistId gesetzt, das andere bleibt null. So kann in Zukunft dieselbe Serie auch von einer anderen Quelle ergänzt werden. IsSubscribed = true wird direkt beim Anlegen gesetzt — nicht erst nach einem separaten „Abonnieren“-Schritt. Wer eine Serie importiert, will sie sehen. Alles andere wäre ein unnötiger zweiter Klick.

Hörspiel-Scoring: Was ist ein Hörspiel?

Spotify kennt keine Kategorie „Hörspiel“. Die Spotify-API liefert Künstler zurück — Musiker, Bands, Podcaster und Hörspiele liegen im selben Topf. Der SpotifyHoerspielScorer bewertet jeden Künstler anhand mehrerer Regeln. Er prüft, ob der Name typische Hörspiel-Keywords enthält (wie „drei“, „fragezeichen“, „tkkg“ oder „EUROPA“), ob die Genres übereinstimmen und wie viele Alben der Künstler hat. Das Ergebnis ist ein HoerspielScoreResult mit einem Score von 0 bis 100 und einem IsHoerspiel-Flag. In der Suchergebnis-UI werden Nicht-Hörspiele ausgegraut oder komplett ausgeblendet.

ImportViewModel: Die UI-Seite des Imports

Das ImportViewModel koordiniert die Suche aus Sicht der Benutzeroberfläche. Es ruft den ImportService auf und packt die Ergebnisse in ViewModel-Objekte, die das XAML darstellen kann.

public async Task SearchAsync()
{
    IsLoading = true;
    Results.Clear();

    try
    {
        IReadOnlyList<ImportSeries> results = await _importService.SearchAsync(SearchQuery);

        foreach (ImportSeries series in results)
        {
            Results.Add(new ImportResultViewModel(series));
        }
    }
    finally
    {
        IsLoading = false;
    }
}

ImportResultViewModel ist ein einfaches Wrapper-ViewModel mit einer ImportCommand-Property. Wenn der Nutzer auf „Importieren“ klickt, ruft es _importService.ImportAsync(series) auf und navigiert danach zur Serienübersicht. Das try/finally-Muster stellt sicher, dass IsLoading immer zurückgesetzt wird — auch wenn ein Fehler auftritt.

Was nicht gemacht werden sollte

Ein typischer Anfängerfehler wäre, die Spotify-API direkt in der Page aufzurufen — ohne DI, ohne Abstraktionsschicht, ohne Fehlerbehandlung:

// FALSCH: Direkt in der Page nach Spotify suchen
SpotifyApiClient client = new(httpClient, options); // Keine DI
IReadOnlyList<SpotifyArtistDto> artists = await client.SearchArtistsAsync(query, 20);

Der Provider-Wechsel (Spotify zu Apple Music) würde nicht funktionieren. Die Duplikatprüfung fehlt. Logging fehlt. Fehlerbehandlung fehlt. Genauso falsch wäre ein hartcodierter Provider ohne AppSettings:

// FALSCH: Hartcodierter Provider ohne AppSettings
ISeriesImportSearch search = new SpotifySeriesImportSearch(...);

Wenn der Nutzer Apple Music auswählt, zeigt die Suche trotzdem Spotify-Ergebnisse. Die richtige Lösung: AppSettings lesen, Keyed Service auflösen, die Logik im ImportService belassen — und die UI ruft nur das Interface auf.

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