diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 1ede9dba..42295eb1 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -158,6 +158,7 @@ type tidalPublicArtistPage struct { Rows []struct { Modules []struct { Type string `json:"type"` + Title string `json:"title"` Artist struct { ID int64 `json:"id"` Name string `json:"name"` @@ -397,17 +398,29 @@ func tidalAlbumToAlbumInfo(album *tidalPublicAlbum) AlbumInfoMetadata { } func tidalAlbumToArtistAlbum(album *tidalPublicAlbum) ArtistAlbumMetadata { + return tidalAlbumToArtistAlbumWithType(album, "") +} + +func tidalAlbumToArtistAlbumWithType(album *tidalPublicAlbum, fallbackType string) ArtistAlbumMetadata { if album == nil { return ArtistAlbumMetadata{} } + albumType := strings.ToLower(strings.TrimSpace(album.Type)) + if albumType == "" { + albumType = strings.ToLower(strings.TrimSpace(fallbackType)) + } + if albumType == "" { + albumType = "album" + } + return ArtistAlbumMetadata{ ID: tidalPrefixedNumericID(album.ID), Name: strings.TrimSpace(album.Title), ReleaseDate: strings.TrimSpace(album.ReleaseDate), TotalTracks: album.NumberOfTracks, Images: tidalImageURL(album.Cover, "1280x1280"), - AlbumType: strings.ToLower(strings.TrimSpace(album.Type)), + AlbumType: albumType, Artists: tidalAlbumArtistsDisplay(album), } } @@ -425,6 +438,18 @@ func tidalPlaylistOwnerName(playlist *tidalPublicPlaylist) string { return "TIDAL" } +func tidalArtistAlbumTypeFromModuleTitle(title string) string { + normalized := strings.ToLower(strings.TrimSpace(title)) + switch normalized { + case "albums", "compilations", "appears on": + return "album" + case "ep & singles", "eps & singles", "singles", "ep", "eps": + return "single" + default: + return "" + } +} + func tidalBuildMetadataURL(path string, extraQuery url.Values) string { trimmedPath := strings.TrimLeft(strings.TrimSpace(path), "/") if trimmedPath == "" { @@ -595,6 +620,7 @@ func findTidalAlbumPageModule(page *tidalPublicAlbumPage, moduleType string) *st func findTidalArtistPageModule(page *tidalPublicArtistPage, moduleType string) *struct { Type string `json:"type"` + Title string `json:"title"` Artist struct { ID int64 `json:"id"` Name string `json:"name"` @@ -790,7 +816,7 @@ func (t *TidalDownloader) GetPlaylistMetadata(resourceID string) (*PlaylistRespo var info PlaylistInfoMetadata info.Tracks.Total = totalTracks info.Name = strings.TrimSpace(playlist.Title) - info.Images = tidalImageURL(tidalFirstNonEmpty(playlist.SquareImage, playlist.Image), "1280x1280") + info.Images = tidalImageURL(tidalFirstNonEmpty(playlist.SquareImage, playlist.Image), "origin") info.Owner.DisplayName = tidalPlaylistOwnerName(playlist) info.Owner.Name = strings.TrimSpace(playlist.Title) info.Owner.Images = info.Images @@ -817,29 +843,53 @@ func (t *TidalDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP } albums := make([]ArtistAlbumMetadata, 0, albumsModule.PagedList.TotalNumberOfItems) - for _, album := range albumsModule.PagedList.Items { - albums = append(albums, tidalAlbumToArtistAlbum(&album)) + seenAlbumIDs := make(map[string]struct{}) + + appendArtistAlbum := func(album tidalPublicAlbum, fallbackType string) { + mapped := tidalAlbumToArtistAlbumWithType(&album, fallbackType) + if mapped.ID == "" { + return + } + if _, exists := seenAlbumIDs[mapped.ID]; exists { + return + } + seenAlbumIDs[mapped.ID] = struct{}{} + albums = append(albums, mapped) } - pageSize := albumsModule.PagedList.Limit - if pageSize <= 0 { - pageSize = 50 - } - offset := len(albumsModule.PagedList.Items) - for offset < albumsModule.PagedList.TotalNumberOfItems && strings.TrimSpace(albumsModule.PagedList.DataAPIPath) != "" { - albumsPage, pageErr := t.getArtistAlbumsPage(albumsModule.PagedList.DataAPIPath, offset, pageSize) - if pageErr != nil { - return nil, pageErr - } + for rowIndex := range page.Rows { + for moduleIndex := range page.Rows[rowIndex].Modules { + module := &page.Rows[rowIndex].Modules[moduleIndex] + if module.Type != "ALBUM_LIST" { + continue + } - for _, album := range albumsPage.Items { - albums = append(albums, tidalAlbumToArtistAlbum(&album)) - } + fallbackType := tidalArtistAlbumTypeFromModuleTitle(module.Title) + for _, album := range module.PagedList.Items { + appendArtistAlbum(album, fallbackType) + } - if len(albumsPage.Items) == 0 || offset+len(albumsPage.Items) >= albumsPage.TotalNumberOfItems { - break + pageSize := module.PagedList.Limit + if pageSize <= 0 { + pageSize = 50 + } + offset := len(module.PagedList.Items) + for offset < module.PagedList.TotalNumberOfItems && strings.TrimSpace(module.PagedList.DataAPIPath) != "" { + albumsPage, pageErr := t.getArtistAlbumsPage(module.PagedList.DataAPIPath, offset, pageSize) + if pageErr != nil { + return nil, pageErr + } + + for _, album := range albumsPage.Items { + appendArtistAlbum(album, fallbackType) + } + + if len(albumsPage.Items) == 0 || offset+len(albumsPage.Items) >= albumsPage.TotalNumberOfItems { + break + } + offset += len(albumsPage.Items) + } } - offset += len(albumsPage.Items) } return &ArtistResponsePayload{ diff --git a/go_backend/tidal_test.go b/go_backend/tidal_test.go index 647ebaf0..bc8a56dc 100644 --- a/go_backend/tidal_test.go +++ b/go_backend/tidal_test.go @@ -162,6 +162,47 @@ func TestTidalAlbumToArtistAlbum(t *testing.T) { } } +func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) { + album := &tidalPublicAlbum{ + ID: 490623904, + Title: "LET 'EM KNOW", + Cover: "fc18a64b-d76b-4582-962a-224cb05193f3", + NumberOfTracks: 1, + } + + got := tidalAlbumToArtistAlbumWithType(album, "single") + if got.AlbumType != "single" { + t.Fatalf("unexpected fallback album type: %q", got.AlbumType) + } +} + +func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) { + tests := []struct { + title string + want string + }{ + {title: "Albums", want: "album"}, + {title: "EP & Singles", want: "single"}, + {title: "Compilations", want: "album"}, + {title: "Appears On", want: "album"}, + {title: "Unknown", want: ""}, + } + + for _, test := range tests { + if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want { + t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want) + } + } +} + +func TestTidalPlaylistImageUsesOrigin(t *testing.T) { + got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin") + want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg" + if got != want { + t.Fatalf("unexpected origin playlist image URL: %q", got) + } +} + func TestTidalPlaylistOwnerName(t *testing.T) { editorial := &tidalPublicPlaylist{Type: "EDITORIAL"} if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" { diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 2a36676c..30511899 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -425,11 +425,15 @@ class TrackNotifier extends Notifier { .map((t) => _parseTrack(t as Map)) .toList(); final owner = playlistInfo['owner'] as Map?; + final playlistName = + (playlistInfo['name'] ?? owner?['name']) as String?; + final coverUrl = + (playlistInfo['images'] ?? owner?['images']) as String?; state = TrackState( tracks: tracks, isLoading: false, - playlistName: owner?['name'] as String?, - coverUrl: owner?['images'] as String?, + playlistName: playlistName, + coverUrl: coverUrl, ); _preWarmCacheForTracks(tracks); } else if (type == 'artist') { @@ -491,11 +495,15 @@ class TrackNotifier extends Notifier { .map((t) => _parseTrack(t as Map)) .toList(); final owner = playlistInfo['owner'] as Map?; + final playlistName = + (playlistInfo['name'] ?? owner?['name']) as String?; + final coverUrl = + (playlistInfo['images'] ?? owner?['images']) as String?; state = TrackState( tracks: tracks, isLoading: false, - playlistName: owner?['name'] as String?, - coverUrl: owner?['images'] as String?, + playlistName: playlistName, + coverUrl: coverUrl, ); _preWarmCacheForTracks(tracks); } else if (type == 'artist') { @@ -574,11 +582,15 @@ class TrackNotifier extends Notifier { .map((t) => _parseTrack(t as Map)) .toList(); final owner = playlistInfo['owner'] as Map?; + final playlistName = + (playlistInfo['name'] ?? owner?['name']) as String?; + final coverUrl = + (playlistInfo['images'] ?? owner?['images']) as String?; state = TrackState( tracks: tracks, isLoading: false, - playlistName: owner?['name'] as String?, - coverUrl: owner?['images'] as String?, + playlistName: playlistName, + coverUrl: coverUrl, ); _preWarmCacheForTracks(tracks); } else if (type == 'artist') { diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index b76c0886..ba16a01f 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -39,8 +39,12 @@ class _PlaylistScreenState extends ConsumerState { List? _fetchedTracks; bool _isLoading = false; String? _error; + String? _resolvedPlaylistName; + String? _resolvedCoverUrl; List get _tracks => _fetchedTracks ?? widget.tracks; + String get _playlistName => _resolvedPlaylistName ?? widget.playlistName; + String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl; @override void initState() { @@ -81,6 +85,9 @@ class _PlaylistScreenState extends ConsumerState { } if (!mounted) return; + final playlistInfo = result['playlist_info'] as Map?; + final owner = playlistInfo?['owner'] as Map?; + // Go backend returns 'track_list' not 'tracks' final trackList = result['track_list'] as List? ?? []; final tracks = trackList @@ -89,6 +96,10 @@ class _PlaylistScreenState extends ConsumerState { setState(() { _fetchedTracks = tracks; + _resolvedPlaylistName = (playlistInfo?['name'] ?? owner?['name']) + ?.toString(); + _resolvedCoverUrl = (playlistInfo?['images'] ?? owner?['images']) + ?.toString(); _isLoading = false; }); } catch (e) { @@ -188,7 +199,7 @@ class _PlaylistScreenState extends ConsumerState { duration: const Duration(milliseconds: 200), opacity: _showTitleInAppBar ? 1.0 : 0.0, child: Text( - widget.playlistName, + _playlistName, style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w600, @@ -210,10 +221,9 @@ class _PlaylistScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - if (widget.coverUrl != null) + if (_coverUrl != null) CachedNetworkImage( - imageUrl: - _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, + imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => @@ -260,7 +270,7 @@ class _PlaylistScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - widget.playlistName, + _playlistName, style: const TextStyle( color: Colors.white, fontSize: 24, @@ -424,7 +434,7 @@ class _PlaylistScreenState extends ConsumerState { track, service, qualityOverride: quality, - playlistName: widget.playlistName, + playlistName: _playlistName, ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -439,7 +449,7 @@ class _PlaylistScreenState extends ConsumerState { .addToQueue( track, settings.defaultService, - playlistName: widget.playlistName, + playlistName: _playlistName, ); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), @@ -603,7 +613,7 @@ class _PlaylistScreenState extends ConsumerState { DownloadServicePicker.show( context, trackName: '${tracks.length} tracks', - artistName: widget.playlistName, + artistName: _playlistName, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) @@ -611,7 +621,7 @@ class _PlaylistScreenState extends ConsumerState { tracks, service, qualityOverride: quality, - playlistName: widget.playlistName, + playlistName: _playlistName, ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -628,7 +638,7 @@ class _PlaylistScreenState extends ConsumerState { .addMultipleToQueue( tracks, settings.defaultService, - playlistName: widget.playlistName, + playlistName: _playlistName, ); ScaffoldMessenger.of(context).showSnackBar( SnackBar(