diff --git a/allstarr/Controllers/JellyfinController.Spotify.cs b/allstarr/Controllers/JellyfinController.Spotify.cs index 2980b002..deae44c3 100644 --- a/allstarr/Controllers/JellyfinController.Spotify.cs +++ b/allstarr/Controllers/JellyfinController.Spotify.cs @@ -496,7 +496,7 @@ public partial class JellyfinController } // Look for the track in cache folder first - var cacheBasePath = "/tmp/allstarr-cache"; + var cacheBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache"); var cacheArtistPath = Path.Combine(cacheBasePath, AdminHelperService.SanitizeFileName(song.Artist)); var cacheAlbumPath = Path.Combine(cacheArtistPath, AdminHelperService.SanitizeFileName(song.Album)); diff --git a/allstarr/Controllers/PlaylistController.cs b/allstarr/Controllers/PlaylistController.cs index 4a92d362..df1c5d9d 100644 --- a/allstarr/Controllers/PlaylistController.cs +++ b/allstarr/Controllers/PlaylistController.cs @@ -9,6 +9,7 @@ using allstarr.Services.Admin; using allstarr.Services; using allstarr.Filters; using System.Text.Json; +using Microsoft.Extensions.Configuration; namespace allstarr.Controllers; @@ -27,6 +28,7 @@ public class PlaylistController : ControllerBase private readonly HttpClient _jellyfinHttpClient; private readonly AdminHelperService _helperService; private readonly IServiceProvider _serviceProvider; + private readonly IConfiguration _configuration; private const string CacheDirectory = "/app/cache/spotify"; public PlaylistController( @@ -37,6 +39,7 @@ public class PlaylistController : ControllerBase SpotifyMappingService mappingService, RedisCacheService cache, IHttpClientFactory httpClientFactory, + IConfiguration configuration, AdminHelperService helperService, IServiceProvider serviceProvider, SpotifyTrackMatchingService? matchingService = null) @@ -49,6 +52,7 @@ public class PlaylistController : ControllerBase _mappingService = mappingService; _cache = cache; _jellyfinHttpClient = httpClientFactory.CreateClient(); + _configuration = configuration; _helperService = helperService; _serviceProvider = serviceProvider; } @@ -753,7 +757,7 @@ public class PlaylistController : ControllerBase if (string.IsNullOrWhiteSpace(externalProvider) && globalMappingExt?.TargetType == "external") { - externalProvider = NormalizeExternalProviderForDisplay(globalMappingExt.ExternalProvider); + externalProvider = ResolvePreferredExternalProvider(globalMappingExt); } // Fallback 3: derive provider from external item ID prefix (ext-{provider}-...) @@ -864,7 +868,7 @@ public class PlaylistController : ControllerBase else if (globalMapping.TargetType == "external") { isLocal = false; - externalProvider = NormalizeExternalProviderForDisplay(globalMapping.ExternalProvider); + externalProvider = ResolvePreferredExternalProvider(globalMapping); isManualMapping = true; manualMappingType = "external"; manualMappingId = globalMapping.ExternalId; @@ -1766,6 +1770,33 @@ public class PlaylistController : ControllerBase return trimmed; } + private string? ResolvePreferredExternalProvider(SpotifyTrackMapping mapping) + { + var preferredProvider = GetCurrentMusicServiceProvider(); + if (mapping.TryGetExternalTarget(preferredProvider, out var provider, out _)) + { + return NormalizeExternalProviderForDisplay(provider); + } + + return NormalizeExternalProviderForDisplay(mapping.ExternalProvider); + } + + private string? GetCurrentMusicServiceProvider() + { + var backendType = _configuration.GetValue("Backend:Type"); + var musicService = backendType == BackendType.Jellyfin + ? _configuration.GetValue("Jellyfin:MusicService") + : _configuration.GetValue("Subsonic:MusicService"); + + return musicService switch + { + MusicService.Deezer => "deezer", + MusicService.Qobuz => "qobuz", + MusicService.SquidWTF => "squidwtf", + _ => null + }; + } + /// /// Rebuild all playlists from scratch (clear cache, fetch fresh data, re-match). /// This is a manual bulk action across all playlists - used by "Rebuild All Remote" button. diff --git a/allstarr/Models/Spotify/SpotifyTrackMapping.cs b/allstarr/Models/Spotify/SpotifyTrackMapping.cs index 6cf0b564..620be034 100644 --- a/allstarr/Models/Spotify/SpotifyTrackMapping.cs +++ b/allstarr/Models/Spotify/SpotifyTrackMapping.cs @@ -30,6 +30,13 @@ public class SpotifyTrackMapping /// External provider track ID (if TargetType is "external") /// public string? ExternalId { get; set; } + + /// + /// Multi-provider external mappings for this Spotify track. + /// Keeps additional external provider IDs so rematch/rebuild can add + /// provider-specific mappings without replacing existing ones. + /// + public List ExternalMappings { get; set; } = new(); /// /// Track metadata for display purposes @@ -79,6 +86,55 @@ public class SpotifyTrackMapping return false; } + + /// + /// Resolves the best external mapping target, preferring the requested provider when available. + /// + public bool TryGetExternalTarget(string? preferredProvider, out string provider, out string externalId) + { + provider = string.Empty; + externalId = string.Empty; + + if (!string.IsNullOrWhiteSpace(preferredProvider)) + { + var preferred = ExternalMappings.FirstOrDefault(m => + string.Equals(m.Provider, preferredProvider, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(m.ExternalId)); + if (preferred != null) + { + provider = preferred.Provider; + externalId = preferred.ExternalId; + return true; + } + } + + var first = ExternalMappings.FirstOrDefault(m => + !string.IsNullOrWhiteSpace(m.Provider) && !string.IsNullOrWhiteSpace(m.ExternalId)); + if (first != null) + { + provider = first.Provider; + externalId = first.ExternalId; + return true; + } + + if (!string.IsNullOrWhiteSpace(ExternalProvider) && !string.IsNullOrWhiteSpace(ExternalId)) + { + provider = ExternalProvider; + externalId = ExternalId; + return true; + } + + return false; + } +} + +public class ExternalTrackMapping +{ + public required string Provider { get; set; } + public required string ExternalId { get; set; } + public string Source { get; set; } = "auto"; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; set; } } /// diff --git a/allstarr/Services/Common/StreamQualityHelper.cs b/allstarr/Services/Common/StreamQualityHelper.cs index 2b5f8101..ee0b7f26 100644 --- a/allstarr/Services/Common/StreamQualityHelper.cs +++ b/allstarr/Services/Common/StreamQualityHelper.cs @@ -62,7 +62,8 @@ public static class StreamQualityHelper return StreamQuality.Original; } - return MapBitRateToQuality((int)(maxBitrate / 1000)); + // MaxStreamingBitrate is reported in bits per second. + return MapBitRateToQuality((int)maxBitrate); } // Check for audioBitRate (lowercase variant used by some clients) diff --git a/allstarr/Services/Deezer/DeezerDownloadService.cs b/allstarr/Services/Deezer/DeezerDownloadService.cs index 531da450..71619a7f 100644 --- a/allstarr/Services/Deezer/DeezerDownloadService.cs +++ b/allstarr/Services/Deezer/DeezerDownloadService.cs @@ -181,10 +181,10 @@ public class DeezerDownloadService : BaseDownloadService _ => ".mp3" }; - // Write to transcoded cache directory: {downloads}/transcoded/Artist/Album/song.ext + // Write to transcoded cache directory: {DownloadPath}/transcoded/Artist/Album/song.ext // These files are cleaned up by CacheCleanupService based on CACHE_TRANSCODE_MINUTES TTL var artistForPath = song.AlbumArtist ?? song.Artist; - var basePath = Path.Combine("downloads", "transcoded"); + var basePath = Path.Combine(DownloadPath, "transcoded"); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "deezer", trackId); // Create directories if they don't exist diff --git a/allstarr/Services/Deezer/DeezerMetadataService.cs b/allstarr/Services/Deezer/DeezerMetadataService.cs index cb2085c9..484562a1 100644 --- a/allstarr/Services/Deezer/DeezerMetadataService.cs +++ b/allstarr/Services/Deezer/DeezerMetadataService.cs @@ -157,7 +157,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return null; + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null; var url = $"{BaseUrl}/track/{externalId}"; var response = await _httpClient.GetAsync(url, cancellationToken); @@ -249,7 +249,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return null; + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null; var url = $"{BaseUrl}/album/{externalId}"; var response = await _httpClient.GetAsync(url, cancellationToken); @@ -291,7 +291,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return null; + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null; var url = $"{BaseUrl}/artist/{externalId}"; var response = await _httpClient.GetAsync(url, cancellationToken); @@ -308,7 +308,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return new List(); + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return new List(); var url = $"{BaseUrl}/artist/{externalId}/albums"; var response = await _httpClient.GetAsync(url, cancellationToken); @@ -332,7 +332,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return new List(); + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return new List(); var url = $"{BaseUrl}/artist/{externalId}/top?limit=50"; var response = await _httpClient.GetAsync(url, cancellationToken); @@ -613,7 +613,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task GetPlaylistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return null; + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null; try { @@ -637,7 +637,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return new List(); + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return new List(); try { diff --git a/allstarr/Services/Spotify/SpotifyMappingService.cs b/allstarr/Services/Spotify/SpotifyMappingService.cs index dec7bdfe..c5fa953e 100644 --- a/allstarr/Services/Spotify/SpotifyMappingService.cs +++ b/allstarr/Services/Spotify/SpotifyMappingService.cs @@ -31,6 +31,7 @@ public class SpotifyMappingService if (mapping != null) { + EnsureExternalMappingsConsistency(mapping); _logger.LogDebug("Found mapping for Spotify ID {SpotifyId}: {TargetType}", spotifyId, mapping.TargetType); } @@ -44,6 +45,8 @@ public class SpotifyMappingService /// public async Task SaveMappingAsync(SpotifyTrackMapping mapping) { + EnsureExternalMappingsConsistency(mapping); + // Validate mapping if (string.IsNullOrEmpty(mapping.SpotifyId)) { @@ -58,9 +61,9 @@ public class SpotifyMappingService } if (mapping.TargetType == "external" && - (string.IsNullOrEmpty(mapping.ExternalProvider) || string.IsNullOrEmpty(mapping.ExternalId))) + !mapping.TryGetExternalTarget(preferredProvider: null, out _, out _)) { - _logger.LogWarning("Cannot save external mapping: ExternalProvider and ExternalId are required"); + _logger.LogWarning("Cannot save external mapping: at least one external provider/id is required"); return false; } @@ -69,6 +72,37 @@ public class SpotifyMappingService // Check if mapping already exists var existingMapping = await GetMappingAsync(mapping.SpotifyId); + // For external mappings, merge provider-specific mappings so rematch/rebuild + // can retain multiple sources (e.g. SquidWTF + Deezer) for the same Spotify ID. + if (mapping.TargetType == "external") + { + if (existingMapping != null && existingMapping.TargetType == "local") + { + var localMappingWithExternalAlternatives = existingMapping; + MergeExternalMappings(localMappingWithExternalAlternatives, mapping); + localMappingWithExternalAlternatives.UpdatedAt = DateTime.UtcNow; + + var saveLocalWithAlternatives = await _cache.SetAsync(key, localMappingWithExternalAlternatives, expiry: null); + if (saveLocalWithAlternatives) + { + await AddToAllMappingsSetAsync(localMappingWithExternalAlternatives.SpotifyId); + await InvalidateAllPlaylistStatsCachesAsync(); + _logger.LogInformation( + "Saved external fallback for local mapping: Spotify {SpotifyId} -> {Provider}:{ExternalId}", + mapping.SpotifyId, + mapping.ExternalProvider, + mapping.ExternalId); + } + + return saveLocalWithAlternatives; + } + + if (existingMapping != null && existingMapping.TargetType == "external") + { + MergeExternalMappings(mapping, existingMapping); + } + } + // RULE 1: Never overwrite manual mappings with auto mappings if (existingMapping != null && existingMapping.Source == "manual" && @@ -84,6 +118,7 @@ public class SpotifyMappingService mapping.TargetType == "local") { _logger.LogInformation("🎉 UPGRADING: External → Local for {SpotifyId}", mapping.SpotifyId); + MergeExternalMappings(mapping, existingMapping); // Allow the upgrade to proceed } @@ -106,8 +141,14 @@ public class SpotifyMappingService if (existingMapping != null) { mapping.CreatedAt = existingMapping.CreatedAt; + if (mapping.TargetType == "local" || mapping.TargetType == "external") + { + MergeExternalMappings(mapping, existingMapping); + } } + EnsureExternalMappingsConsistency(mapping); + // Save mapping (permanent - no TTL) var success = await _cache.SetAsync(key, mapping, expiry: null); @@ -168,6 +209,16 @@ public class SpotifyMappingService TargetType = "external", ExternalProvider = externalProvider, ExternalId = externalId, + ExternalMappings = new List + { + new() + { + Provider = externalProvider, + ExternalId = externalId, + Source = "auto", + CreatedAt = DateTime.UtcNow + } + }, Metadata = metadata, Source = "auto", CreatedAt = DateTime.UtcNow @@ -194,6 +245,20 @@ public class SpotifyMappingService LocalId = localId, ExternalProvider = externalProvider, ExternalId = externalId, + ExternalMappings = targetType == "external" && + !string.IsNullOrWhiteSpace(externalProvider) && + !string.IsNullOrWhiteSpace(externalId) + ? new List + { + new() + { + Provider = externalProvider, + ExternalId = externalId, + Source = "manual", + CreatedAt = DateTime.UtcNow + } + } + : new List(), Metadata = metadata, Source = "manual", CreatedAt = DateTime.UtcNow, @@ -377,6 +442,87 @@ public class SpotifyMappingService _logger.LogWarning(ex, "Failed to invalidate playlist stats caches"); } } + + private static void MergeExternalMappings(SpotifyTrackMapping target, SpotifyTrackMapping source) + { + if (source.TryGetExternalTarget(preferredProvider: null, out var sourceProvider, out var sourceExternalId)) + { + UpsertExternalMapping( + target.ExternalMappings, + sourceProvider, + sourceExternalId, + source.Source); + } + + foreach (var mapping in source.ExternalMappings) + { + if (string.IsNullOrWhiteSpace(mapping.Provider) || string.IsNullOrWhiteSpace(mapping.ExternalId)) + { + continue; + } + + UpsertExternalMapping( + target.ExternalMappings, + mapping.Provider, + mapping.ExternalId, + mapping.Source); + } + } + + private static void UpsertExternalMapping( + List externalMappings, + string provider, + string externalId, + string source) + { + var normalizedProvider = provider.Trim().ToLowerInvariant(); + var existing = externalMappings.FirstOrDefault(m => + string.Equals(m.Provider, normalizedProvider, StringComparison.OrdinalIgnoreCase)); + + if (existing == null) + { + externalMappings.Add(new ExternalTrackMapping + { + Provider = normalizedProvider, + ExternalId = externalId, + Source = source, + CreatedAt = DateTime.UtcNow + }); + return; + } + + if (!string.Equals(existing.ExternalId, externalId, StringComparison.Ordinal)) + { + existing.ExternalId = externalId; + existing.UpdatedAt = DateTime.UtcNow; + } + + if (!string.IsNullOrWhiteSpace(source)) + { + existing.Source = source; + } + } + + private static void EnsureExternalMappingsConsistency(SpotifyTrackMapping mapping) + { + mapping.ExternalMappings ??= new List(); + + if (!string.IsNullOrWhiteSpace(mapping.ExternalProvider) && !string.IsNullOrWhiteSpace(mapping.ExternalId)) + { + UpsertExternalMapping( + mapping.ExternalMappings, + mapping.ExternalProvider, + mapping.ExternalId, + mapping.Source); + } + + if (mapping.ExternalMappings.Count > 0) + { + var first = mapping.ExternalMappings[0]; + mapping.ExternalProvider = first.Provider; + mapping.ExternalId = first.ExternalId; + } + } } /// diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index 2e1581e6..ce99af60 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -4,6 +4,7 @@ using allstarr.Models.Spotify; using allstarr.Services.Common; using allstarr.Services.Jellyfin; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using System.Text.Json; using Cronos; @@ -36,6 +37,7 @@ public class SpotifyTrackMatchingService : BackgroundService private readonly SpotifyMappingValidationService _validationService; private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; + private readonly IConfiguration _configuration; private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count) private static readonly TimeSpan ExternalProviderSearchTimeout = TimeSpan.FromSeconds(30); @@ -51,6 +53,7 @@ public class SpotifyTrackMatchingService : BackgroundService SpotifyMappingService mappingService, SpotifyMappingValidationService validationService, IServiceProvider serviceProvider, + IConfiguration configuration, ILogger logger) { _spotifySettings = spotifySettings.Value; @@ -59,6 +62,7 @@ public class SpotifyTrackMatchingService : BackgroundService _mappingService = mappingService; _validationService = validationService; _serviceProvider = serviceProvider; + _configuration = configuration; _logger = logger; } @@ -803,13 +807,13 @@ public class SpotifyTrackMatchingService : BackgroundService if (globalMapping != null && globalMapping.TargetType == "external") { Song? mappedSong = null; + var preferredProvider = GetCurrentMusicServiceProvider(); - if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) && - !string.IsNullOrEmpty(globalMapping.ExternalId)) + if (globalMapping.TryGetExternalTarget(preferredProvider, out var mappedProvider, out var mappedExternalId)) { mappedSong = await metadataService.GetSongAsync( - globalMapping.ExternalProvider, - globalMapping.ExternalId, + mappedProvider, + mappedExternalId, trackCancellationToken); } @@ -2036,4 +2040,20 @@ public class SpotifyTrackMatchingService : BackgroundService return song; } + + private string? GetCurrentMusicServiceProvider() + { + var backendType = _configuration.GetValue("Backend:Type"); + var musicService = backendType == BackendType.Jellyfin + ? _configuration.GetValue("Jellyfin:MusicService") + : _configuration.GetValue("Subsonic:MusicService"); + + return musicService switch + { + MusicService.Deezer => "deezer", + MusicService.Qobuz => "qobuz", + MusicService.SquidWTF => "squidwtf", + _ => null + }; + } }