fix: fix Tidal track resolution, playlist owner info, and improve track provider state

This commit is contained in:
zarzet
2026-03-14 15:41:57 +07:00
parent ac9141f167
commit 733efce161
4 changed files with 149 additions and 36 deletions
+70 -20
View File
@@ -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{
+41
View File
@@ -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" {
+18 -6
View File
@@ -425,11 +425,15 @@ class TrackNotifier extends Notifier<TrackState> {
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
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<TrackState> {
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
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<TrackState> {
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
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') {
+20 -10
View File
@@ -39,8 +39,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
List<Track>? _fetchedTracks;
bool _isLoading = false;
String? _error;
String? _resolvedPlaylistName;
String? _resolvedCoverUrl;
List<Track> 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<PlaylistScreen> {
}
if (!mounted) return;
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
final owner = playlistInfo?['owner'] as Map<String, dynamic>?;
// Go backend returns 'track_list' not 'tracks'
final trackList = result['track_list'] as List<dynamic>? ?? [];
final tracks = trackList
@@ -89,6 +96,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
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<PlaylistScreen> {
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<PlaylistScreen> {
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<PlaylistScreen> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.playlistName,
_playlistName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
@@ -424,7 +434,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
track,
service,
qualityOverride: quality,
playlistName: widget.playlistName,
playlistName: _playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -439,7 +449,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
.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<PlaylistScreen> {
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<PlaylistScreen> {
tracks,
service,
qualityOverride: quality,
playlistName: widget.playlistName,
playlistName: _playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -628,7 +638,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
.addMultipleToQueue(
tracks,
settings.defaultService,
playlistName: widget.playlistName,
playlistName: _playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(