diff --git a/go_backend/exports.go b/go_backend/exports.go index 457a3e6e..39184350 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -128,6 +128,7 @@ type DownloadResult struct { TrackNumber int DiscNumber int ISRC string + CoverURL string Genre string Label string Copyright string @@ -214,6 +215,11 @@ func buildDownloadSuccessResponse( copyright = req.Copyright } + coverURL := strings.TrimSpace(result.CoverURL) + if coverURL == "" { + coverURL = strings.TrimSpace(req.CoverURL) + } + return DownloadResponse{ Success: true, Message: message, @@ -230,7 +236,7 @@ func buildDownloadSuccessResponse( TrackNumber: trackNumber, DiscNumber: discNumber, ISRC: isrc, - CoverURL: req.CoverURL, + CoverURL: coverURL, Genre: genre, Label: label, Copyright: copyright, @@ -378,6 +384,7 @@ func DownloadTrack(requestJSON string) (string, error) { TrackNumber: qobuzResult.TrackNumber, DiscNumber: qobuzResult.DiscNumber, ISRC: qobuzResult.ISRC, + CoverURL: qobuzResult.CoverURL, LyricsLRC: qobuzResult.LyricsLRC, } } @@ -586,6 +593,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { TrackNumber: qobuzResult.TrackNumber, DiscNumber: qobuzResult.DiscNumber, ISRC: qobuzResult.ISRC, + CoverURL: qobuzResult.CoverURL, LyricsLRC: qobuzResult.LyricsLRC, } } else if !errors.Is(qobuzErr, ErrDownloadCancelled) { diff --git a/go_backend/exports_test.go b/go_backend/exports_test.go index dbce4278..44faf294 100644 --- a/go_backend/exports_test.go +++ b/go_backend/exports_test.go @@ -84,3 +84,32 @@ func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) { t.Fatalf("disc number = %d", discNumber) } } + +func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) { + req := DownloadRequest{ + TrackName: "Track", + ArtistName: "Artist", + AlbumName: "Album", + AlbumArtist: "Artist", + } + + result := DownloadResult{ + Title: "Track", + Artist: "Artist", + Album: "Album", + CoverURL: "https://cdn.qobuz.test/cover.jpg", + } + + resp := buildDownloadSuccessResponse( + req, + result, + "qobuz", + "ok", + "/tmp/test.flac", + false, + ) + + if resp.CoverURL != result.CoverURL { + t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL) + } +} diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index b9f79bc4..a3e46450 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -1480,6 +1480,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon TrackNumber: qobuzResult.TrackNumber, DiscNumber: qobuzResult.DiscNumber, ISRC: qobuzResult.ISRC, + CoverURL: qobuzResult.CoverURL, } } err = qobuzErr @@ -1522,6 +1523,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon TrackNumber: result.TrackNumber, DiscNumber: result.DiscNumber, ISRC: result.ISRC, + CoverURL: result.CoverURL, Genre: req.Genre, Label: req.Label, Copyright: req.Copyright, diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index dc7d160a..cba711b0 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -2067,6 +2067,7 @@ type QobuzDownloadResult struct { TrackNumber int DiscNumber int ISRC string + CoverURL string LyricsLRC string } @@ -2260,7 +2261,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { parallelDone := make(chan struct{}) go func() { defer close(parallelDone) - coverURL := req.CoverURL + coverURL := strings.TrimSpace(req.CoverURL) + if coverURL == "" { + coverURL = strings.TrimSpace(qobuzTrackAlbumImage(track)) + } embedLyrics := req.EmbedLyrics if !req.EmbedMetadata { coverURL = "" @@ -2393,6 +2397,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { TrackNumber: resultTrackNumber, DiscNumber: resultDiscNumber, ISRC: track.ISRC, + CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)), LyricsLRC: lyricsLRC, }, nil } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 32b3b76c..c2f2eed6 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -34,6 +34,7 @@ class AppSettings { final bool enableLogging; final bool useExtensionProviders; final String? searchProvider; + final String? homeFeedProvider; final bool separateSingles; final String albumFolderStructure; final bool showExtensionStore; @@ -113,6 +114,7 @@ class AppSettings { this.enableLogging = false, this.useExtensionProviders = true, this.searchProvider, + this.homeFeedProvider, this.separateSingles = false, this.albumFolderStructure = 'artist_album', this.showExtensionStore = true, @@ -179,6 +181,8 @@ class AppSettings { bool? useExtensionProviders, String? searchProvider, bool clearSearchProvider = false, + String? homeFeedProvider, + bool clearHomeFeedProvider = false, bool? separateSingles, String? albumFolderStructure, bool? showExtensionStore, @@ -244,6 +248,9 @@ class AppSettings { searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider), + homeFeedProvider: clearHomeFeedProvider + ? null + : (homeFeedProvider ?? this.homeFeedProvider), separateSingles: separateSingles ?? this.separateSingles, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, showExtensionStore: showExtensionStore ?? this.showExtensionStore, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 85244329..914e224f 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -39,6 +39,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( enableLogging: json['enableLogging'] as bool? ?? false, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, searchProvider: json['searchProvider'] as String?, + homeFeedProvider: json['homeFeedProvider'] as String?, separateSingles: json['separateSingles'] as bool? ?? false, albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album', @@ -117,6 +118,7 @@ Map _$AppSettingsToJson( 'enableLogging': instance.enableLogging, 'useExtensionProviders': instance.useExtensionProviders, 'searchProvider': instance.searchProvider, + 'homeFeedProvider': instance.homeFeedProvider, 'separateSingles': instance.separateSingles, 'albumFolderStructure': instance.albumFolderStructure, 'showExtensionStore': instance.showExtensionStore, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 22c834bd..a457b3d2 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -122,7 +122,7 @@ class DownloadHistoryItem { artistName: json['artistName'] as String, albumName: json['albumName'] as String, albumArtist: normalizeOptionalString(json['albumArtist'] as String?), - coverUrl: json['coverUrl'] as String?, + coverUrl: normalizeCoverReference(json['coverUrl']?.toString()), filePath: json['filePath'] as String, storageMode: json['storageMode'] as String?, downloadTreeUri: json['downloadTreeUri'] as String?, @@ -176,7 +176,7 @@ class DownloadHistoryItem { artistName: artistName ?? this.artistName, albumName: albumName ?? this.albumName, albumArtist: albumArtist ?? this.albumArtist, - coverUrl: coverUrl ?? this.coverUrl, + coverUrl: normalizeCoverReference(coverUrl ?? this.coverUrl), filePath: filePath ?? this.filePath, storageMode: storageMode ?? this.storageMode, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, @@ -2534,8 +2534,8 @@ class DownloadQueueNotifier extends Notifier { final backendIsrc = normalizeOptionalString( backendResult['isrc'] as String?, ); - final backendCoverUrl = normalizeOptionalString( - backendResult['cover_url'] as String?, + final backendCoverUrl = normalizeCoverReference( + backendResult['cover_url']?.toString(), ); final backendAlbumArtist = normalizeOptionalString( backendResult['album_artist'] as String?, @@ -2591,7 +2591,7 @@ class DownloadQueueNotifier extends Notifier { } String? coverPath; - var coverUrl = track.coverUrl; + var coverUrl = normalizeRemoteHttpUrl(track.coverUrl); if (coverUrl != null && coverUrl.isNotEmpty) { try { if (settings.maxQualityCover) { @@ -2777,7 +2777,7 @@ class DownloadQueueNotifier extends Notifier { } String? coverPath; - var coverUrl = track.coverUrl; + var coverUrl = normalizeRemoteHttpUrl(track.coverUrl); if (coverUrl != null && coverUrl.isNotEmpty) { try { if (settings.maxQualityCover) { @@ -2945,7 +2945,7 @@ class DownloadQueueNotifier extends Notifier { } String? coverPath; - var coverUrl = track.coverUrl; + var coverUrl = normalizeRemoteHttpUrl(track.coverUrl); if (coverUrl != null && coverUrl.isNotEmpty) { try { if (settings.maxQualityCover) { @@ -3751,7 +3751,7 @@ class DownloadQueueNotifier extends Notifier { albumArtist: trackToDownload.albumArtist, artistId: trackToDownload.artistId, albumId: trackToDownload.albumId, - coverUrl: trackToDownload.coverUrl, + coverUrl: normalizeCoverReference(trackToDownload.coverUrl), duration: trackToDownload.duration, isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc)) ? deezerIsrc @@ -4041,6 +4041,14 @@ class DownloadQueueNotifier extends Notifier { item.service.toLowerCase(); final decryptionKey = (result['decryption_key'] as String?)?.trim() ?? ''; + trackToDownload = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, + ); + _log.d( + 'Track coverUrl after download result: ${trackToDownload.coverUrl}', + ); if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) { _log.i('Encrypted stream detected, decrypting via FFmpeg...'); @@ -4959,7 +4967,7 @@ class DownloadQueueNotifier extends Notifier { ? backendAlbum : trackToDownload.albumName, albumArtist: historyAlbumArtist, - coverUrl: trackToDownload.coverUrl, + coverUrl: normalizeCoverReference(trackToDownload.coverUrl), filePath: filePath, storageMode: effectiveSafMode ? 'saf' : 'app', downloadTreeUri: effectiveSafMode diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index cd2b01a7..e4ffa5f7 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; final _log = AppLogger('ExploreProvider'); @@ -202,9 +203,7 @@ class ExploreNotifier extends Notifier { Future _saveToCache(List sections) async { try { final prefs = await SharedPreferences.getInstance(); - final data = { - 'sections': sections.map((s) => s.toJson()).toList(), - }; + final data = {'sections': sections.map((s) => s.toJson()).toList()}; await prefs.setString(_cacheKey, jsonEncode(data)); await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch); _log.d('Saved ${sections.length} explore sections to cache'); @@ -216,16 +215,16 @@ class ExploreNotifier extends Notifier { /// Fetch home feed from spotify-web extension Future fetchHomeFeed({bool forceRefresh = false}) async { _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh'); - + // If we have cached content and it's fresh enough, skip network fetch - if (!forceRefresh && - state.hasContent && + if (!forceRefresh && + state.hasContent && state.lastFetched != null && DateTime.now().difference(state.lastFetched!).inMinutes < 5) { _log.d('Using cached home feed (fresh enough)'); return; } - + if (state.isLoading) { _log.d('Home feed fetch already in progress'); return; @@ -237,21 +236,33 @@ class ExploreNotifier extends Notifier { try { final extState = ref.read(extensionProvider); - _log.d('Extensions count: ${extState.extensions.length}'); - + final settings = ref.read(settingsProvider); + final preferredId = settings.homeFeedProvider; + _log.d( + 'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId', + ); + Extension? targetExt; for (final extension in extState.extensions) { if (!extension.enabled || !extension.hasHomeFeed) { continue; } + // If user has a preference, use that + if (preferredId != null && + preferredId.isNotEmpty && + extension.id == preferredId) { + targetExt = extension; + break; + } + // Otherwise take the first available (fallback to spotify-web if found) if (targetExt == null || extension.id == 'spotify-web') { targetExt = extension; - if (extension.id == 'spotify-web') { + if (preferredId == null && extension.id == 'spotify-web') { break; } } } - + if (targetExt == null) { _log.w('No extension with homeFeed capability found'); state = state.copyWith( @@ -260,7 +271,7 @@ class ExploreNotifier extends Notifier { ); return; } - + _log.i('Fetching home feed from ${targetExt.id}...'); final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id); @@ -276,10 +287,7 @@ class ExploreNotifier extends Notifier { _log.d('getExtensionHomeFeed success=$success'); if (!success) { final error = result['error'] as String? ?? 'Unknown error'; - state = state.copyWith( - isLoading: false, - error: error, - ); + state = state.copyWith(isLoading: false, error: error); return; } @@ -291,10 +299,12 @@ class ExploreNotifier extends Notifier { .toList(); _log.i('Fetched ${sections.length} sections'); - + if (sections.isNotEmpty && sections.first.items.isNotEmpty) { final firstItem = sections.first.items.first; - _log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}'); + _log.d( + 'First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}', + ); } final localGreeting = _getLocalGreeting(); @@ -311,10 +321,7 @@ class ExploreNotifier extends Notifier { _saveToCache(sections); } catch (e, stack) { _log.e('Error fetching home feed: $e', e, stack); - state = state.copyWith( - isLoading: false, - error: e.toString(), - ); + state = state.copyWith(isLoading: false, error: e.toString()); } } @@ -325,7 +332,6 @@ class ExploreNotifier extends Notifier { Future refresh() => fetchHomeFeed(forceRefresh: true); } - final exploreProvider = NotifierProvider(() { return ExploreNotifier(); }); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 7b89df1a..f2309d5d 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -424,6 +424,15 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setHomeFeedProvider(String? provider) { + if (provider == null || provider.isEmpty) { + state = state.copyWith(clearHomeFeedProvider: true); + } else { + state = state.copyWith(homeFeedProvider: provider); + } + _saveSettings(); + } + void setEnableLogging(bool enabled) { state = state.copyWith(enableLogging: enabled); _saveSettings(); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index eee4fc4b..5de7d16b 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -286,7 +287,9 @@ class TrackNotifier extends Notifier { playlistName: type == 'playlist' ? result['name'] as String? : null, - coverUrl: result['cover_url'] as String?, + coverUrl: normalizeCoverReference( + result['cover_url']?.toString(), + ), searchExtensionId: extensionId, ); return; @@ -313,10 +316,12 @@ class TrackNotifier extends Notifier { isLoading: false, artistId: artistData['id'] as String?, artistName: artistData['name'] as String?, - coverUrl: - artistData['image_url'] as String? ?? - artistData['images'] as String?, - headerImageUrl: artistData['header_image'] as String?, + coverUrl: normalizeRemoteHttpUrl( + (artistData['image_url'] ?? artistData['images'])?.toString(), + ), + headerImageUrl: normalizeRemoteHttpUrl( + artistData['header_image']?.toString(), + ), monthlyListeners: artistData['listeners'] as int?, artistAlbums: albums, artistTopTracks: topTracks.isNotEmpty ? topTracks : null, @@ -357,7 +362,7 @@ class TrackNotifier extends Notifier { isLoading: false, albumId: id, albumName: albumInfo['name'] as String?, - coverUrl: albumInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()), ); _preWarmCacheForTracks(tracks); } else if (type == 'playlist') { @@ -371,7 +376,9 @@ class TrackNotifier extends Notifier { tracks: tracks, isLoading: false, playlistName: playlistInfo['name'] as String?, - coverUrl: playlistInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl( + playlistInfo['images']?.toString(), + ), ); _preWarmCacheForTracks(tracks); } else if (type == 'artist') { @@ -385,7 +392,7 @@ class TrackNotifier extends Notifier { isLoading: false, artistId: artistInfo['id'] as String?, artistName: artistInfo['name'] as String?, - coverUrl: artistInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()), artistAlbums: albums, ); } @@ -422,7 +429,7 @@ class TrackNotifier extends Notifier { isLoading: false, albumId: 'qobuz:$id', albumName: albumInfo['name'] as String?, - coverUrl: albumInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()), ); _preWarmCacheForTracks(tracks); } else if (type == 'playlist') { @@ -435,8 +442,9 @@ class TrackNotifier extends Notifier { final owner = playlistInfo['owner'] as Map?; final playlistName = (playlistInfo['name'] ?? owner?['name']) as String?; - final coverUrl = - (playlistInfo['images'] ?? owner?['images']) as String?; + final coverUrl = normalizeRemoteHttpUrl( + (playlistInfo['images'] ?? owner?['images'])?.toString(), + ); state = TrackState( tracks: tracks, isLoading: false, @@ -455,7 +463,7 @@ class TrackNotifier extends Notifier { isLoading: false, artistId: artistInfo['id'] as String?, artistName: artistInfo['name'] as String?, - coverUrl: artistInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()), artistAlbums: albums, ); } @@ -492,7 +500,7 @@ class TrackNotifier extends Notifier { isLoading: false, albumId: 'tidal:$id', albumName: albumInfo['name'] as String?, - coverUrl: albumInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()), ); _preWarmCacheForTracks(tracks); } else if (type == 'playlist') { @@ -505,8 +513,9 @@ class TrackNotifier extends Notifier { final owner = playlistInfo['owner'] as Map?; final playlistName = (playlistInfo['name'] ?? owner?['name']) as String?; - final coverUrl = - (playlistInfo['images'] ?? owner?['images']) as String?; + final coverUrl = normalizeRemoteHttpUrl( + (playlistInfo['images'] ?? owner?['images'])?.toString(), + ); state = TrackState( tracks: tracks, isLoading: false, @@ -525,7 +534,7 @@ class TrackNotifier extends Notifier { isLoading: false, artistId: artistInfo['id'] as String?, artistName: artistInfo['name'] as String?, - coverUrl: artistInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()), artistAlbums: albums, ); } @@ -580,7 +589,7 @@ class TrackNotifier extends Notifier { isLoading: false, albumId: parsed['id'] as String?, albumName: albumInfo['name'] as String?, - coverUrl: albumInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()), ); _preWarmCacheForTracks(tracks); } else if (type == 'playlist') { @@ -592,8 +601,9 @@ class TrackNotifier extends Notifier { final owner = playlistInfo['owner'] as Map?; final playlistName = (playlistInfo['name'] ?? owner?['name']) as String?; - final coverUrl = - (playlistInfo['images'] ?? owner?['images']) as String?; + final coverUrl = normalizeRemoteHttpUrl( + (playlistInfo['images'] ?? owner?['images'])?.toString(), + ); state = TrackState( tracks: tracks, isLoading: false, @@ -612,7 +622,7 @@ class TrackNotifier extends Notifier { isLoading: false, artistId: artistInfo['id'] as String?, artistName: artistInfo['name'] as String?, - coverUrl: artistInfo['images'] as String?, + coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()), artistAlbums: albums, ); } @@ -986,7 +996,7 @@ class TrackNotifier extends Notifier { albumArtist: data['album_artist'] as String?, artistId: (data['artist_id'] ?? data['artistId'])?.toString(), albumId: data['album_id']?.toString(), - coverUrl: data['images'] as String?, + coverUrl: normalizeCoverReference(data['images']?.toString()), isrc: data['isrc'] as String?, duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, @@ -1017,7 +1027,9 @@ class TrackNotifier extends Notifier { albumArtist: data['album_artist']?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString(), albumId: data['album_id']?.toString(), - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + coverUrl: normalizeCoverReference( + (data['cover_url'] ?? data['images'])?.toString(), + ), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, @@ -1062,7 +1074,9 @@ class TrackNotifier extends Notifier { name: data['name'] as String? ?? '', releaseDate: data['release_date'] as String? ?? '', totalTracks: data['total_tracks'] as int? ?? 0, - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + coverUrl: normalizeCoverReference( + (data['cover_url'] ?? data['images'])?.toString(), + ), albumType: data['album_type'] as String? ?? 'album', artists: data['artists'] as String? ?? '', providerId: data['provider_id']?.toString(), @@ -1073,7 +1087,7 @@ class TrackNotifier extends Notifier { return SearchArtist( id: data['id'] as String? ?? '', name: data['name'] as String? ?? '', - imageUrl: data['images'] as String?, + imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()), followers: data['followers'] as int? ?? 0, popularity: data['popularity'] as int? ?? 0, ); @@ -1084,7 +1098,7 @@ class TrackNotifier extends Notifier { id: data['id'] as String? ?? '', name: data['name'] as String? ?? '', artists: data['artists'] as String? ?? '', - imageUrl: data['images'] as String?, + imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()), releaseDate: data['release_date'] as String?, totalTracks: data['total_tracks'] as int? ?? 0, albumType: data['album_type'] as String? ?? 'album', @@ -1096,7 +1110,7 @@ class TrackNotifier extends Notifier { id: data['id'] as String? ?? '', name: data['name'] as String? ?? '', owner: data['owner'] as String? ?? '', - imageUrl: data['images'] as String?, + imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()), totalTracks: data['total_tracks'] as int? ?? 0, ); } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index b54fb92a..3f020953 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; @@ -94,7 +95,10 @@ class _AlbumScreenState extends ConsumerState { .recordAlbumAccess( id: widget.albumId, name: widget.albumName, - artistName: widget.artistName ?? widget.tracks?.firstOrNull?.albumArtist ?? widget.tracks?.firstOrNull?.artistName, + artistName: + widget.artistName ?? + widget.tracks?.firstOrNull?.albumArtist ?? + widget.tracks?.firstOrNull?.artistName, imageUrl: widget.coverUrl, providerId: providerId, ); @@ -226,7 +230,7 @@ class _AlbumScreenState extends ConsumerState { artistId: (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, albumId: data['album_id']?.toString() ?? widget.albumId, - coverUrl: data['images'] as String?, + coverUrl: normalizeCoverReference(data['images']?.toString()), isrc: data['isrc'] as String?, duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), trackNumber: data['track_number'] as int?, @@ -280,7 +284,8 @@ class _AlbumScreenState extends ConsumerState { ) { final expandedHeight = _calculateExpandedHeight(context); final tracks = _tracks ?? []; - final artistName = widget.artistName ?? + final artistName = + widget.artistName ?? (tracks.isNotEmpty ? (tracks.first.albumArtist ?? tracks.first.artistName) : null); @@ -574,17 +579,21 @@ class _AlbumScreenState extends ConsumerState { // Skip already-downloaded tracks final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); - final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) + final localLibState = + (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) ? ref.read(localLibraryProvider) : null; final tracksToQueue = []; int skippedCount = 0; for (final track in tracks) { - final isInHistory = historyState.isDownloaded(track.id) || + final isInHistory = + historyState.isDownloaded(track.id) || (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || - historyState.findByTrackAndArtist(track.name, track.artistName) != null; - final isInLocal = localLibState?.existsInLibrary( + historyState.findByTrackAndArtist(track.name, track.artistName) != + null; + final isInLocal = + localLibState?.existsInLibrary( isrc: track.isrc, trackName: track.name, artistName: track.artistName, @@ -617,7 +626,11 @@ class _AlbumScreenState extends ConsumerState { onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracksToQueue, service, qualityOverride: quality); + .addMultipleToQueue( + tracksToQueue, + service, + qualityOverride: quality, + ); _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); }, ); @@ -633,9 +646,9 @@ class _AlbumScreenState extends ConsumerState { final message = skipped > 0 ? context.l10n.discographySkippedDownloaded(added, skipped) : context.l10n.snackbarAddedTracksToQueue(added); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } Widget _buildLoveAllButton() { diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 3efc2d88..7aefcd64 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; @@ -297,8 +298,7 @@ class _ArtistScreenState extends ConsumerState { .toList(); } - final topTracksList = - artistData['top_tracks'] as List? ?? []; + final topTracksList = artistData['top_tracks'] as List? ?? []; if (topTracksList.isNotEmpty) { topTracks = topTracksList .map((t) => _parseTrack(t as Map)) @@ -399,8 +399,9 @@ class _ArtistScreenState extends ConsumerState { (data['artist_id'] ?? data['artistId'])?.toString() ?? widget.artistId, albumId: data['album_id']?.toString() ?? album?.id, - coverUrl: (data['cover_url'] ?? data['images'] ?? album?.coverUrl) - ?.toString(), + coverUrl: normalizeCoverReference( + (data['cover_url'] ?? data['images'] ?? album?.coverUrl)?.toString(), + ), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, @@ -414,18 +415,18 @@ class _ArtistScreenState extends ConsumerState { ArtistAlbum _parseArtistAlbum(Map data) { final totalTracksValue = data['total_tracks']; - final totalTracks = - totalTracksValue is int - ? totalTracksValue - : int.tryParse(totalTracksValue?.toString() ?? '') ?? 0; + final totalTracks = totalTracksValue is int + ? totalTracksValue + : int.tryParse(totalTracksValue?.toString() ?? '') ?? 0; return ArtistAlbum( id: data['id'] as String? ?? '', name: (data['name'] ?? data['title'] ?? '').toString(), releaseDate: (data['release_date'] ?? '').toString(), totalTracks: totalTracks, - coverUrl: (data['cover_url'] ?? data['images'] ?? data['cover_art']) - ?.toString(), + coverUrl: normalizeCoverReference( + (data['cover_url'] ?? data['images'] ?? data['cover_art'])?.toString(), + ), albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(), artists: (data['artists'] ?? data['artist'] ?? widget.artistName) .toString(), @@ -1359,8 +1360,10 @@ class _ArtistScreenState extends ConsumerState { }, itemBuilder: (context, pageIndex) { final startIndex = pageIndex * tracksPerPage; - final endIndex = - (startIndex + tracksPerPage).clamp(0, tracks.length); + final endIndex = (startIndex + tracksPerPage).clamp( + 0, + tracks.length, + ); final pageTracks = tracks.sublist(startIndex, endIndex); return Column( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index eeef064e..ca022604 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -23,6 +23,7 @@ import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.da import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; @@ -4306,7 +4307,7 @@ class _ExtensionArtistScreenState extends ConsumerState { artists: (data['artists'] ?? '').toString(), releaseDate: (data['release_date'] ?? '').toString(), totalTracks: data['total_tracks'] as int? ?? 0, - coverUrl: data['cover_url']?.toString(), + coverUrl: normalizeCoverReference(data['cover_url']?.toString()), albumType: (data['album_type'] ?? 'album').toString(), providerId: (data['provider_id'] ?? widget.extensionId).toString(), ); @@ -4331,7 +4332,9 @@ class _ExtensionArtistScreenState extends ConsumerState { (data['artist_id'] ?? data['artistId'])?.toString() ?? widget.artistId, albumId: data['album_id']?.toString(), - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + coverUrl: normalizeCoverReference( + (data['cover_url'] ?? data['images'])?.toString(), + ), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index c2d9bff3..fb4818ea 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; @@ -128,7 +129,9 @@ class _PlaylistScreenState extends ConsumerState { albumArtist: data['album_artist']?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString(), albumId: data['album_id']?.toString(), - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + coverUrl: normalizeCoverReference( + (data['cover_url'] ?? data['images'])?.toString(), + ), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, @@ -532,7 +535,12 @@ class _PlaylistScreenState extends ConsumerState { tooltip: context.l10n.tooltipAddToPlaylist, onPressed: _tracks.isEmpty ? null - : () => showAddTracksToPlaylistSheet(context, ref, _tracks, playlistNamePrefill: widget.playlistName), + : () => showAddTracksToPlaylistSheet( + context, + ref, + _tracks, + playlistNamePrefill: widget.playlistName, + ), ); } @@ -611,17 +619,21 @@ class _PlaylistScreenState extends ConsumerState { // Skip already-downloaded tracks final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); - final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) + final localLibState = + (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) ? ref.read(localLibraryProvider) : null; final tracksToQueue = []; int skippedCount = 0; for (final track in tracks) { - final isInHistory = historyState.isDownloaded(track.id) || + final isInHistory = + historyState.isDownloaded(track.id) || (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || - historyState.findByTrackAndArtist(track.name, track.artistName) != null; - final isInLocal = localLibState?.existsInLibrary( + historyState.findByTrackAndArtist(track.name, track.artistName) != + null; + final isInLocal = + localLibState?.existsInLibrary( isrc: track.isrc, trackName: track.name, artistName: track.artistName, @@ -679,9 +691,9 @@ class _PlaylistScreenState extends ConsumerState { final message = skipped > 0 ? context.l10n.discographySkippedDownloaded(added, skipped) : context.l10n.snackbarAddedTracksToQueue(added); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 81102a3e..09f0175c 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -519,7 +519,8 @@ class _TrackMetadataScreenState extends ConsumerState { String get _filePath => _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; - String? get _coverUrl => _isLocalItem ? null : _downloadItem!.coverUrl; + String? get _coverUrl => + _isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl); String? get _localCoverPath => _isLocalItem ? _localLibraryItem!.coverPath : null; String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId; diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart index 9f4cc164..2e2b704c 100644 --- a/lib/utils/string_utils.dart +++ b/lib/utils/string_utils.dart @@ -6,6 +6,41 @@ String? normalizeOptionalString(String? value) { return trimmed; } +final RegExp _windowsAbsolutePathPattern = RegExp(r'^[A-Za-z]:[\\/]'); + +bool _looksLikeLocalReference(String value) { + return value.startsWith('/') || + value.startsWith('content://') || + value.startsWith('file://') || + _windowsAbsolutePathPattern.hasMatch(value); +} + +String? normalizeCoverReference(String? value) { + final normalized = normalizeOptionalString(value); + if (normalized == null) return null; + + if (normalized.startsWith('//')) { + return 'https:$normalized'; + } + + if (normalized.startsWith('http://') || + normalized.startsWith('https://') || + _looksLikeLocalReference(normalized)) { + return normalized; + } + + return null; +} + +String? normalizeRemoteHttpUrl(String? value) { + final normalized = normalizeCoverReference(value); + if (normalized == null) return null; + if (normalized.startsWith('http://') || normalized.startsWith('https://')) { + return normalized; + } + return null; +} + String formatSampleRateKHz(int sampleRate) { final khz = sampleRate / 1000; final precision = sampleRate % 1000 == 0 ? 0 : 1;