mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-22 15:59:46 +02:00
fix: fix Tidal track resolution, playlist owner info, and improve track provider state
This commit is contained in:
+70
-20
@@ -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{
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user