diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index aca778de..51a64a3e 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1122,7 +1122,6 @@ class MainActivity: FlutterFragmentActivity() { var tempCuePath: String? = null var tempAudioPath: String? = null try { - // Copy CUE to temp tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") if (tempCuePath == null) { errors++ @@ -1131,10 +1130,8 @@ class MainActivity: FlutterFragmentActivity() { continue } - // Extract the audio filename from the CUE sheet text val audioFileName = extractCueAudioFileName(tempCuePath) - // Find the referenced audio file as a sibling in the same SAF directory val audioDoc = resolveCueAudioSibling( parentDir = parentDir, cueName = cueName, @@ -1176,7 +1173,6 @@ class MainActivity: FlutterFragmentActivity() { val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L } - // Call Go to produce library scan entries for each CUE track val cueResultsJson = Gobackend.scanCueSheetForLibrary( tempCuePath, tempDir, @@ -2624,6 +2620,22 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "getQobuzMetadata" -> { + val resourceType = call.argument("resource_type") ?: "" + val resourceId = call.argument("resource_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getQobuzMetadata(resourceType, resourceId) + } + result.success(response) + } + "getTidalMetadata" -> { + val resourceType = call.argument("resource_type") ?: "" + val resourceId = call.argument("resource_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getTidalMetadata(resourceType, resourceId) + } + result.success(response) + } "parseDeezerUrl" -> { val url = call.argument("url") ?: "" val response = withContext(Dispatchers.IO) { @@ -2631,6 +2643,13 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "parseQobuzUrl" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.parseQobuzURLExport(url) + } + result.success(response) + } "parseTidalUrl" -> { val url = call.argument("url") ?: "" val response = withContext(Dispatchers.IO) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 44fa7831..447f7685 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1156,6 +1156,66 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) { return string(jsonBytes), nil } +func GetQobuzMetadata(resourceType, resourceID string) (string, error) { + downloader := NewQobuzDownloader() + + var data interface{} + var err error + + switch resourceType { + case "track": + data, err = downloader.GetTrackMetadata(resourceID) + case "album": + data, err = downloader.GetAlbumMetadata(resourceID) + case "artist": + data, err = downloader.GetArtistMetadata(resourceID) + case "playlist": + data, err = downloader.GetPlaylistMetadata(resourceID) + default: + return "", fmt.Errorf("unsupported Qobuz resource type: %s", resourceType) + } + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func GetTidalMetadata(resourceType, resourceID string) (string, error) { + downloader := NewTidalDownloader() + + var data interface{} + var err error + + switch resourceType { + case "track": + data, err = downloader.GetTrackMetadata(resourceID) + case "album": + data, err = downloader.GetAlbumMetadata(resourceID) + case "artist": + data, err = downloader.GetArtistMetadata(resourceID) + case "playlist": + data, err = downloader.GetPlaylistMetadata(resourceID) + default: + return "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType) + } + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + func ParseDeezerURLExport(url string) (string, error) { resourceType, resourceID, err := parseDeezerURL(url) if err != nil { @@ -1175,6 +1235,25 @@ func ParseDeezerURLExport(url string) (string, error) { return string(jsonBytes), nil } +func ParseQobuzURLExport(url string) (string, error) { + resourceType, resourceID, err := parseQobuzURL(url) + if err != nil { + return "", err + } + + result := map[string]string{ + "type": resourceType, + "id": resourceID, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + func ParseTidalURLExport(url string) (string, error) { resourceType, resourceID, err := parseTidalURL(url) if err != nil { @@ -1311,7 +1390,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { return string(jsonBytes), nil } - // For artists/playlists, SongLink doesn't provide direct mapping return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType) } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 092c0e96..d51e45de 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -28,13 +28,29 @@ type QobuzDownloader struct { var ( globalQobuzDownloader *QobuzDownloader qobuzDownloaderOnce sync.Once + qobuzGetTrackByIDFunc = func(q *QobuzDownloader, trackID int64) (*QobuzTrack, error) { + return q.GetTrackByID(trackID) + } + qobuzSearchTrackByISRCWithDurationFunc = func(q *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) { + return q.SearchTrackByISRCWithDuration(isrc, expectedDurationSec) + } + qobuzSearchTrackByMetadataWithDurationFunc = func(q *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { + return q.SearchTrackByMetadataWithDuration(trackName, artistName, expectedDurationSec) + } + songLinkCheckTrackAvailabilityFunc = func(client *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) { + return client.CheckTrackAvailability(spotifyTrackID, isrc) + } ) const ( qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id=" qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query=" + qobuzAlbumGetBaseURL = "https://www.qobuz.com/api.json/0.2/album/get?album_id=" + qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id=" + qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id=" qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/" qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" + qobuzStoreBaseURL = "https://www.qobuz.com/us-en" qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" @@ -43,6 +59,8 @@ const ( ) var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`) +var qobuzArtistAlbumIDRegex = regexp.MustCompile(`data-itemtype="album"\s+data-itemId="([A-Za-z0-9]+)"`) +var qobuzLocaleSegmentRegex = regexp.MustCompile(`^[a-z]{2}-[a-z]{2}$`) var qobuzDebugKeyObfuscated = []byte{ 0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b, @@ -58,20 +76,406 @@ type QobuzTrack struct { ISRC string `json:"isrc"` Duration int `json:"duration"` TrackNumber int `json:"track_number"` + MediaNumber int `json:"media_number"` MaximumBitDepth int `json:"maximum_bit_depth"` MaximumSamplingRate float64 `json:"maximum_sampling_rate"` + Version string `json:"version"` Album struct { + ID string `json:"id"` + QobuzID int64 `json:"qobuz_id"` + TracksCount int `json:"tracks_count"` Title string `json:"title"` ReleaseDate string `json:"release_date_original"` - Image struct { - Large string `json:"large"` + ProductType string `json:"product_type"` + ReleaseType string `json:"release_type"` + Artist struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"artist"` + Artists []qobuzArtistRef `json:"artists"` + Image struct { + Thumbnail string `json:"thumbnail"` + Small string `json:"small"` + Large string `json:"large"` } `json:"image"` } `json:"album"` Performer struct { + ID int64 `json:"id"` Name string `json:"name"` } `json:"performer"` } +type qobuzImageSet struct { + Thumbnail string `json:"thumbnail"` + Small string `json:"small"` + Large string `json:"large"` +} + +type qobuzArtistRef struct { + ID int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type qobuzLabelRef struct { + Name string `json:"name"` +} + +type qobuzGenreRef struct { + Name string `json:"name"` +} + +type qobuzAlbumDetails struct { + ID string `json:"id"` + QobuzID int64 `json:"qobuz_id"` + Title string `json:"title"` + ReleaseDateOriginal string `json:"release_date_original"` + TracksCount int `json:"tracks_count"` + ProductType string `json:"product_type"` + ReleaseType string `json:"release_type"` + Image qobuzImageSet `json:"image"` + Artist qobuzArtistRef `json:"artist"` + Artists []qobuzArtistRef `json:"artists"` + Genre qobuzGenreRef `json:"genre"` + Label qobuzLabelRef `json:"label"` + Copyright string `json:"copyright"` + Tracks struct { + Items []QobuzTrack `json:"items"` + } `json:"tracks"` +} + +type qobuzArtistDetails struct { + ID int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Image qobuzImageSet `json:"image"` +} + +type qobuzPlaylistDetails struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ImageRectangle []string `json:"image_rectangle"` + ImageRectangleMini []string `json:"image_rectangle_mini"` + TracksCount int `json:"tracks_count"` + Owner struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"owner"` + Tracks struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Items []QobuzTrack `json:"items"` + } `json:"tracks"` +} + +func qobuzFirstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func qobuzPrefixedID(id string) string { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return "" + } + if strings.HasPrefix(trimmed, "qobuz:") { + return trimmed + } + return "qobuz:" + trimmed +} + +func qobuzPrefixedNumericID(id int64) string { + if id <= 0 { + return "" + } + return fmt.Sprintf("qobuz:%d", id) +} + +func qobuzNormalizeReleaseDate(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + if _, err := time.Parse("2006-01-02", trimmed); err == nil { + return trimmed + } + if parsed, err := time.Parse("Jan 2, 2006", trimmed); err == nil { + return parsed.Format("2006-01-02") + } + return trimmed +} + +func qobuzNormalizeAlbumType(releaseType, productType string, totalTracks int) string { + kind := strings.ToLower(strings.TrimSpace(releaseType)) + if kind == "" { + kind = strings.ToLower(strings.TrimSpace(productType)) + } + switch kind { + case "album", "single", "ep", "compilation": + return kind + } + if totalTracks > 0 && totalTracks <= 3 { + return "single" + } + return "album" +} + +func qobuzArtistsDisplayName(artists []qobuzArtistRef, fallback string) string { + names := make([]string, 0, len(artists)) + seen := make(map[string]struct{}, len(artists)) + for _, artist := range artists { + name := strings.TrimSpace(artist.Name) + if name == "" { + continue + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + names = append(names, name) + } + if len(names) == 0 { + return strings.TrimSpace(fallback) + } + return strings.Join(names, ", ") +} + +func qobuzTrackDisplayTitle(track *QobuzTrack) string { + if track == nil { + return "" + } + title := strings.TrimSpace(track.Title) + version := strings.TrimSpace(track.Version) + if title == "" || version == "" { + return title + } + return fmt.Sprintf("%s (%s)", title, version) +} + +func qobuzTrackAlbumImage(track *QobuzTrack) string { + if track == nil { + return "" + } + return qobuzFirstNonEmpty( + track.Album.Image.Large, + track.Album.Image.Small, + track.Album.Image.Thumbnail, + ) +} + +func qobuzAlbumImage(album *qobuzAlbumDetails) string { + if album == nil { + return "" + } + return qobuzFirstNonEmpty( + album.Image.Large, + album.Image.Small, + album.Image.Thumbnail, + ) +} + +func qobuzTrackArtistID(track *QobuzTrack) string { + if track == nil { + return "" + } + if track.Performer.ID > 0 { + return qobuzPrefixedNumericID(track.Performer.ID) + } + return qobuzPrefixedNumericID(track.Album.Artist.ID) +} + +func qobuzTrackArtistName(track *QobuzTrack) string { + if track == nil { + return "" + } + return strings.TrimSpace(track.Performer.Name) +} + +func qobuzTrackAlbumArtist(track *QobuzTrack) string { + if track == nil { + return "" + } + return qobuzArtistsDisplayName(track.Album.Artists, track.Album.Artist.Name) +} + +func qobuzTrackAlbumType(track *QobuzTrack) string { + if track == nil { + return "album" + } + return qobuzNormalizeAlbumType( + track.Album.ReleaseType, + track.Album.ProductType, + track.Album.TracksCount, + ) +} + +func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata { + if track == nil { + return TrackMetadata{} + } + return TrackMetadata{ + SpotifyID: qobuzPrefixedNumericID(track.ID), + Artists: qobuzTrackArtistName(track), + Name: qobuzTrackDisplayTitle(track), + AlbumName: strings.TrimSpace(track.Album.Title), + AlbumArtist: qobuzTrackAlbumArtist(track), + DurationMS: track.Duration * 1000, + Images: qobuzTrackAlbumImage(track), + ReleaseDate: qobuzNormalizeReleaseDate(track.Album.ReleaseDate), + TrackNumber: track.TrackNumber, + TotalTracks: track.Album.TracksCount, + DiscNumber: track.MediaNumber, + ExternalURL: fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, track.ID), + ISRC: strings.TrimSpace(track.ISRC), + AlbumID: qobuzPrefixedID(track.Album.ID), + ArtistID: qobuzTrackArtistID(track), + AlbumType: qobuzTrackAlbumType(track), + } +} + +func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata { + if track == nil { + return AlbumTrackMetadata{} + } + return AlbumTrackMetadata{ + SpotifyID: qobuzPrefixedNumericID(track.ID), + Artists: qobuzTrackArtistName(track), + Name: qobuzTrackDisplayTitle(track), + AlbumName: strings.TrimSpace(track.Album.Title), + AlbumArtist: qobuzTrackAlbumArtist(track), + DurationMS: track.Duration * 1000, + Images: qobuzTrackAlbumImage(track), + ReleaseDate: qobuzNormalizeReleaseDate(track.Album.ReleaseDate), + TrackNumber: track.TrackNumber, + TotalTracks: track.Album.TracksCount, + DiscNumber: track.MediaNumber, + ExternalURL: fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, track.ID), + ISRC: strings.TrimSpace(track.ISRC), + AlbumID: qobuzPrefixedID(track.Album.ID), + AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)), + AlbumType: qobuzTrackAlbumType(track), + } +} + +func qobuzAlbumToAlbumInfo(album *qobuzAlbumDetails) AlbumInfoMetadata { + if album == nil { + return AlbumInfoMetadata{} + } + return AlbumInfoMetadata{ + TotalTracks: album.TracksCount, + Name: strings.TrimSpace(album.Title), + ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal), + Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name), + ArtistId: qobuzPrefixedNumericID(album.Artist.ID), + Images: qobuzAlbumImage(album), + Genre: strings.TrimSpace(album.Genre.Name), + Label: strings.TrimSpace(album.Label.Name), + Copyright: strings.TrimSpace(album.Copyright), + } +} + +func qobuzAlbumToArtistAlbum(album *qobuzAlbumDetails) ArtistAlbumMetadata { + if album == nil { + return ArtistAlbumMetadata{} + } + return ArtistAlbumMetadata{ + ID: qobuzPrefixedID(album.ID), + Name: strings.TrimSpace(album.Title), + ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal), + TotalTracks: album.TracksCount, + Images: qobuzAlbumImage(album), + AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount), + Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name), + } +} + +func qobuzSplitPathSegments(path string) []string { + rawSegments := strings.Split(strings.TrimSpace(path), "/") + segments := make([]string, 0, len(rawSegments)) + for _, segment := range rawSegments { + trimmed := strings.TrimSpace(segment) + if trimmed == "" { + continue + } + segments = append(segments, trimmed) + } + if len(segments) > 0 && qobuzLocaleSegmentRegex.MatchString(strings.ToLower(segments[0])) { + return segments[1:] + } + return segments +} + +func qobuzResourceTypeFromSegment(segment string) string { + switch strings.ToLower(strings.TrimSpace(segment)) { + case "album": + return "album" + case "interpreter", "artist": + return "artist" + case "playlist", "playlists": + return "playlist" + case "track": + return "track" + default: + return "" + } +} + +func parseQobuzURL(input string) (string, string, error) { + raw := strings.TrimSpace(input) + if raw == "" { + return "", "", fmt.Errorf("empty Qobuz URL") + } + + if strings.HasPrefix(strings.ToLower(raw), "qobuzapp://") { + parsed, err := url.Parse(raw) + if err != nil { + return "", "", err + } + resourceType := qobuzResourceTypeFromSegment(parsed.Host) + resourceID := strings.Trim(strings.TrimSpace(parsed.Path), "/") + if resourceType == "" || resourceID == "" { + return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") + } + return resourceType, resourceID, nil + } + + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + if !strings.Contains(raw, "://") { + parsed, err = url.Parse("https://" + raw) + } + } + if err != nil || parsed == nil || parsed.Host == "" { + return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") + } + + host := strings.ToLower(parsed.Host) + if host != "qobuz.com" && host != "www.qobuz.com" && host != "play.qobuz.com" { + return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") + } + + segments := qobuzSplitPathSegments(parsed.Path) + if len(segments) < 2 { + return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") + } + + resourceType := qobuzResourceTypeFromSegment(segments[0]) + resourceID := strings.TrimSpace(segments[len(segments)-1]) + if resourceType == "" || resourceID == "" { + return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") + } + + return resourceType, resourceID, nil +} + func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist)) @@ -386,6 +790,232 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { return &track, nil } +func (q *QobuzDownloader) getQobuzJSON(requestURL string, target interface{}) error { + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return err + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("qobuz request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) + } + + return json.NewDecoder(resp.Body).Decode(target) +} + +func (q *QobuzDownloader) getQobuzBody(requestURL string) ([]byte, error) { + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, err + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("qobuz request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) + } + + return io.ReadAll(resp.Body) +} + +func extractQobuzAlbumIDsFromArtistHTML(body []byte) []string { + matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1) + if len(matches) == 0 { + return nil + } + + albumIDs := make([]string, 0, len(matches)) + seen := make(map[string]struct{}, len(matches)) + for _, match := range matches { + if len(match) < 2 { + continue + } + albumID := strings.TrimSpace(string(match[1])) + if albumID == "" { + continue + } + if _, ok := seen[albumID]; ok { + continue + } + seen[albumID] = struct{}{} + albumIDs = append(albumIDs, albumID) + } + return albumIDs +} + +func (q *QobuzDownloader) getAlbumDetails(albumID string) (*qobuzAlbumDetails, error) { + requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzAlbumGetBaseURL, url.QueryEscape(strings.TrimSpace(albumID)), q.appID) + var album qobuzAlbumDetails + if err := q.getQobuzJSON(requestURL, &album); err != nil { + return nil, err + } + return &album, nil +} + +func (q *QobuzDownloader) getArtistDetails(artistID string) (*qobuzArtistDetails, error) { + requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzArtistGetBaseURL, url.QueryEscape(strings.TrimSpace(artistID)), q.appID) + var artist qobuzArtistDetails + if err := q.getQobuzJSON(requestURL, &artist); err != nil { + return nil, err + } + return &artist, nil +} + +func (q *QobuzDownloader) getPlaylistDetailsPage(playlistID string, limit, offset int) (*qobuzPlaylistDetails, error) { + requestURL := fmt.Sprintf( + "%s%s&extra=tracks&limit=%d&offset=%d&app_id=%s", + qobuzPlaylistGetBaseURL, + url.QueryEscape(strings.TrimSpace(playlistID)), + limit, + offset, + q.appID, + ) + var playlist qobuzPlaylistDetails + if err := q.getQobuzJSON(requestURL, &playlist); err != nil { + return nil, err + } + return &playlist, nil +} + +func (q *QobuzDownloader) getArtistAlbumIDs(artistID string) ([]string, error) { + artist, err := q.getArtistDetails(artistID) + if err != nil { + return nil, err + } + + slug := strings.TrimSpace(artist.Slug) + if slug == "" { + slug = "artist" + } + requestURL := fmt.Sprintf("%s/interpreter/%s/%d", qobuzStoreBaseURL, url.PathEscape(slug), artist.ID) + body, err := q.getQobuzBody(requestURL) + if err != nil { + return nil, err + } + + albumIDs := extractQobuzAlbumIDsFromArtistHTML(body) + if len(albumIDs) == 0 { + return nil, fmt.Errorf("artist page did not contain album IDs") + } + return albumIDs, nil +} + +func (q *QobuzDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) { + trackID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64) + if err != nil || trackID <= 0 { + return nil, fmt.Errorf("invalid Qobuz track ID: %s", resourceID) + } + + track, err := q.GetTrackByID(trackID) + if err != nil { + return nil, err + } + + return &TrackResponse{Track: qobuzTrackToTrackMetadata(track)}, nil +} + +func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePayload, error) { + album, err := q.getAlbumDetails(resourceID) + if err != nil { + return nil, err + } + + tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items)) + for i := range album.Tracks.Items { + tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&album.Tracks.Items[i])) + } + + return &AlbumResponsePayload{ + AlbumInfo: qobuzAlbumToAlbumInfo(album), + TrackList: tracks, + }, nil +} + +func (q *QobuzDownloader) GetPlaylistMetadata(resourceID string) (*PlaylistResponsePayload, error) { + const pageSize = 50 + + offset := 0 + var playlistInfo PlaylistInfoMetadata + tracks := make([]AlbumTrackMetadata, 0, pageSize) + + for { + page, err := q.getPlaylistDetailsPage(resourceID, pageSize, offset) + if err != nil { + return nil, err + } + + if offset == 0 { + total := page.Tracks.Total + if total == 0 { + total = page.TracksCount + } + playlistInfo.Tracks.Total = total + playlistInfo.Owner.DisplayName = strings.TrimSpace(page.Owner.Name) + playlistInfo.Owner.Name = strings.TrimSpace(page.Name) + playlistInfo.Owner.Images = qobuzFirstNonEmpty(page.ImageRectangle...) + } + + for i := range page.Tracks.Items { + tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&page.Tracks.Items[i])) + } + + if len(page.Tracks.Items) == 0 || + offset+len(page.Tracks.Items) >= playlistInfo.Tracks.Total || + len(page.Tracks.Items) < pageSize { + break + } + offset += len(page.Tracks.Items) + } + + return &PlaylistResponsePayload{ + PlaylistInfo: playlistInfo, + TrackList: tracks, + }, nil +} + +func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponsePayload, error) { + artist, err := q.getArtistDetails(resourceID) + if err != nil { + return nil, err + } + + albumIDs, err := q.getArtistAlbumIDs(resourceID) + if err != nil { + return nil, err + } + + albums := make([]ArtistAlbumMetadata, 0, len(albumIDs)) + for _, albumID := range albumIDs { + album, albumErr := q.getAlbumDetails(albumID) + if albumErr != nil { + GoLog("[Qobuz] Skipping artist album %s: %v\n", albumID, albumErr) + continue + } + albums = append(albums, qobuzAlbumToArtistAlbum(album)) + } + + return &ArtistResponsePayload{ + ArtistInfo: ArtistInfoMetadata{ + ID: qobuzPrefixedNumericID(artist.ID), + Name: strings.TrimSpace(artist.Name), + Images: qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail), + }, + Albums: albums, + }, nil +} + func (q *QobuzDownloader) GetAvailableAPIs() []string { return []string{ qobuzDownloadAPIURL, @@ -791,6 +1421,39 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) } +func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string) bool { + if track == nil { + return false + } + + if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { + GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n", + logPrefix, source, req.ArtistName, track.Performer.Name) + return false + } + + if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) { + GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n", + logPrefix, source, req.TrackName, track.Title) + return false + } + + expectedDurationSec := req.DurationMS / 1000 + if expectedDurationSec > 0 && track.Duration > 0 { + durationDiff := track.Duration - expectedDurationSec + if durationDiff < 0 { + durationDiff = -durationDiff + } + if durationDiff > 10 { + GoLog("[%s] Duration mismatch from %s: expected %ds, got %ds. Rejecting.\n", + logPrefix, source, expectedDurationSec, track.Duration) + return false + } + } + + return true +} + func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]QobuzTrack, error) { searchURL := fmt.Sprintf("%s%s&limit=%d&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(query), limit, q.appID) req, err := http.NewRequest("GET", searchURL, nil) @@ -1247,6 +1910,19 @@ type QobuzDownloadResult struct { LyricsLRC string } +func parseQobuzRequestTrackID(raw string) int64 { + trimmed := strings.TrimSpace(raw) + trimmed = strings.TrimPrefix(trimmed, "qobuz:") + if trimmed == "" { + return 0 + } + var trackID int64 + if _, err := fmt.Sscanf(trimmed, "%d", &trackID); err != nil || trackID <= 0 { + return 0 + } + return trackID +} + func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloader, logPrefix string) (*QobuzTrack, error) { if downloader == nil { downloader = NewQobuzDownloader() @@ -1260,17 +1936,20 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade var track *QobuzTrack var err error - // Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate) + // Strategy 1: Use Qobuz ID from request payload (fastest, most accurate) if req.QobuzID != "" { - GoLog("[%s] Using Qobuz ID from Odesli enrichment: %s\n", logPrefix, req.QobuzID) - var trackID int64 - if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { - track, err = downloader.GetTrackByID(trackID) + GoLog("[%s] Using Qobuz ID from request payload: %s\n", logPrefix, req.QobuzID) + if trackID := parseQobuzRequestTrackID(req.QobuzID); trackID > 0 { + track, err = qobuzGetTrackByIDFunc(downloader, trackID) if err != nil { - GoLog("[%s] Failed to get track by Odesli ID %d: %v\n", logPrefix, trackID, err) + GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err) track = nil } else if track != nil { - GoLog("[%s] Successfully found track via Odesli ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) + if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID") { + GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) + } else { + track = nil + } } } } @@ -1279,10 +1958,12 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade if track == nil && req.ISRC != "" { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.QobuzTrackID) - track, err = downloader.GetTrackByID(cached.QobuzTrackID) + track, err = qobuzGetTrackByIDFunc(downloader, cached.QobuzTrackID) if err != nil { GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err) track = nil + } else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID") { + track = nil } } } @@ -1291,19 +1972,23 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade if track == nil && req.SpotifyID != "" && req.QobuzID == "" { GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID) songLinkClient := NewSongLinkClient() - availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC) + availability, slErr := songLinkCheckTrackAvailabilityFunc(songLinkClient, req.SpotifyID, req.ISRC) if slErr == nil && availability != nil && availability.QobuzID != "" { var trackID int64 if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { GoLog("[%s] Got Qobuz ID %d from SongLink\n", logPrefix, trackID) - track, err = downloader.GetTrackByID(trackID) + track, err = qobuzGetTrackByIDFunc(downloader, trackID) if err != nil { GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err) track = nil } else if track != nil { - GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) - if req.ISRC != "" { - GetTrackIDCache().SetQobuz(req.ISRC, track.ID) + if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID") { + GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) + if req.ISRC != "" { + GetTrackIDCache().SetQobuz(req.ISRC, track.ID) + } + } else { + track = nil } } } @@ -1313,27 +1998,17 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade // Strategy 4: ISRC search with duration verification if track == nil && req.ISRC != "" { GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC) - track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) - if track != nil { - if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - GoLog("[%s] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", - logPrefix, req.ArtistName, track.Performer.Name) - track = nil - } else if !qobuzTitlesMatch(req.TrackName, track.Title) { - GoLog("[%s] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", - logPrefix, req.TrackName, track.Title) - track = nil - } + track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec) + if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search") { + track = nil } } // Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds) if track == nil { GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName) - track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) - if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - GoLog("[%s] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", - logPrefix, req.ArtistName, track.Performer.Name) + track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec) + if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search") { track = nil } } diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go index 0382b62f..d19629b6 100644 --- a/go_backend/qobuz_test.go +++ b/go_backend/qobuz_test.go @@ -2,6 +2,95 @@ package gobackend import "testing" +func TestParseQobuzURL(t *testing.T) { + tests := []struct { + name string + input string + wantType string + wantID string + expectErr bool + }{ + { + name: "store album url", + input: "https://www.qobuz.com/us-en/album/harry-styles-harry-styles/0886446451985", + wantType: "album", + wantID: "0886446451985", + }, + { + name: "store playlist url", + input: "https://www.qobuz.com/us-en/playlists/new-releases/2049430", + wantType: "playlist", + wantID: "2049430", + }, + { + name: "store artist url", + input: "https://www.qobuz.com/us-en/interpreter/harry-styles/729886", + wantType: "artist", + wantID: "729886", + }, + { + name: "play track url", + input: "https://play.qobuz.com/track/40681594", + wantType: "track", + wantID: "40681594", + }, + { + name: "custom scheme playlist url", + input: "qobuzapp://playlist/2049430", + wantType: "playlist", + wantID: "2049430", + }, + { + name: "unsupported url", + input: "https://example.com/not-qobuz", + expectErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gotType, gotID, err := parseQobuzURL(test.input) + if test.expectErr { + if err == nil { + t.Fatalf("expected error, got none") + } + return + } + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if gotType != test.wantType || gotID != test.wantID { + t.Fatalf("parseQobuzURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID) + } + }) + } +} + +func TestExtractQobuzArtistAlbumIDs(t *testing.T) { + body := []byte(` +
+ +
+
+ +
+
+ +
+`) + + matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1) + if len(matches) != 3 { + t.Fatalf("expected 3 regex matches, got %d", len(matches)) + } + if string(matches[0][1]) != "yrpbt0lwm3g0y" { + t.Fatalf("unexpected first album id: %q", matches[0][1]) + } + if string(matches[2][1]) != "0886446451985" { + t.Fatalf("unexpected last album id: %q", matches[2][1]) + } +} + func TestExtractQobuzDownloadURLFromBody(t *testing.T) { t.Run("reads top-level download_url and quality metadata", func(t *testing.T) { body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`) @@ -106,6 +195,22 @@ func TestGetQobuzDebugKey(t *testing.T) { } } +func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) { + body := []byte(` + + + + `) + + got := extractQobuzAlbumIDsFromArtistHTML(body) + if len(got) != 2 { + t.Fatalf("expected 2 unique album IDs, got %d (%v)", len(got), got) + } + if got[0] != "0886446451985" || got[1] != "pvv406bth40ya" { + t.Fatalf("unexpected album IDs: %v", got) + } +} + func TestQobuzAvailableProviders(t *testing.T) { providers := NewQobuzDownloader().GetAvailableProviders() if len(providers) != 3 { @@ -133,3 +238,174 @@ func TestQobuzAvailableProviders(t *testing.T) { t.Fatalf("missing providers: %v", want) } } + +func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack { + track := &QobuzTrack{ + ID: id, + Title: title, + Duration: duration, + } + track.Performer.Name = artist + return track +} + +func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) { + origGetTrackByID := qobuzGetTrackByIDFunc + origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc + origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc + origSongLinkCheck := songLinkCheckTrackAvailabilityFunc + t.Cleanup(func() { + qobuzGetTrackByIDFunc = origGetTrackByID + qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC + qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata + songLinkCheckTrackAvailabilityFunc = origSongLinkCheck + GetTrackIDCache().Clear() + }) + GetTrackIDCache().Clear() + + qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) { + if trackID != 111 { + t.Fatalf("unexpected track ID lookup: %d", trackID) + } + return testQobuzTrack(111, "Aperture", "Harry Styles", 180), nil + } + qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) { + if isrc != "TESTISRC1" { + t.Fatalf("unexpected ISRC lookup: %q", isrc) + } + if expectedDurationSec != 180 { + t.Fatalf("unexpected duration: %d", expectedDurationSec) + } + return testQobuzTrack(222, "Taste Back", "Harry Styles", 180), nil + } + qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) { + t.Fatal("metadata fallback should not run when ISRC fallback succeeds") + return nil, nil + } + songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) { + if spotifyTrackID != "spotify-track-id" { + t.Fatalf("unexpected spotify ID: %q", spotifyTrackID) + } + if isrc != "TESTISRC1" { + t.Fatalf("unexpected SongLink ISRC: %q", isrc) + } + return &TrackAvailability{QobuzID: "111"}, nil + } + + req := DownloadRequest{ + ISRC: "TESTISRC1", + SpotifyID: "spotify-track-id", + TrackName: "Taste Back", + ArtistName: "Harry Styles", + DurationMS: 180000, + } + + track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if track == nil || track.ID != 222 || track.Title != "Taste Back" { + t.Fatalf("unexpected resolved track: %+v", track) + } + + cached := GetTrackIDCache().Get(req.ISRC) + if cached == nil || cached.QobuzTrackID != 222 { + t.Fatalf("expected validated fallback track to be cached, got %+v", cached) + } +} + +func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) { + origGetTrackByID := qobuzGetTrackByIDFunc + origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc + origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc + origSongLinkCheck := songLinkCheckTrackAvailabilityFunc + t.Cleanup(func() { + qobuzGetTrackByIDFunc = origGetTrackByID + qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC + qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata + songLinkCheckTrackAvailabilityFunc = origSongLinkCheck + }) + + qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) { + if trackID != 333 { + t.Fatalf("unexpected track ID lookup: %d", trackID) + } + return testQobuzTrack(333, "American Girls", "Harry Styles", 181), nil + } + qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) { + t.Fatal("ISRC fallback should not run without an ISRC") + return nil, nil + } + qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { + if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 { + t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec) + } + return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil + } + songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) { + t.Fatal("SongLink should not run when Odesli QobuzID is provided") + return nil, nil + } + + req := DownloadRequest{ + QobuzID: "333", + TrackName: "Taste Back", + ArtistName: "Harry Styles", + DurationMS: 181000, + } + + track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if track == nil || track.ID != 444 || track.Title != "Taste Back" { + t.Fatalf("unexpected resolved track: %+v", track) + } +} + +func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testing.T) { + origGetTrackByID := qobuzGetTrackByIDFunc + origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc + origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc + origSongLinkCheck := songLinkCheckTrackAvailabilityFunc + t.Cleanup(func() { + qobuzGetTrackByIDFunc = origGetTrackByID + qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC + qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata + songLinkCheckTrackAvailabilityFunc = origSongLinkCheck + }) + + qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) { + if trackID != 40681594 { + t.Fatalf("unexpected track ID lookup: %d", trackID) + } + return testQobuzTrack(40681594, "Sign of the Times", "Harry Styles", 341), nil + } + qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) { + t.Fatal("ISRC fallback should not run when request qobuz id succeeds") + return nil, nil + } + qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) { + t.Fatal("metadata fallback should not run when request qobuz id succeeds") + return nil, nil + } + songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) { + t.Fatal("SongLink should not run when request qobuz id is provided") + return nil, nil + } + + req := DownloadRequest{ + QobuzID: "qobuz:40681594", + TrackName: "Sign of the Times", + ArtistName: "Harry Styles", + DurationMS: 341000, + } + + track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if track == nil || track.ID != 40681594 { + t.Fatalf("unexpected resolved track: %+v", track) + } +} diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 1e540110..1ede9dba 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "sync" "time" @@ -32,6 +33,12 @@ var ( const ( spotifyTrackBaseURL = "https://open.spotify.com/track/" songLinkLookupBaseURL = "https://api.song.link/v1-alpha.1/links?url=" + tidalPublicAPIBaseURL = "https://tidal.com/v1" + tidalPublicToken = "txNoH4kkV41MfH25" + tidalResourceBaseURL = "https://resources.tidal.com" + tidalCountryCode = "US" + tidalLocale = "en_US" + tidalDeviceType = "BROWSER" ) type TidalTrack struct { @@ -43,19 +50,28 @@ type TidalTrack struct { VolumeNumber int `json:"volumeNumber"` Duration int `json:"duration"` Album struct { + ID int64 `json:"id"` Title string `json:"title"` Cover string `json:"cover"` ReleaseDate string `json:"releaseDate"` + URL string `json:"url"` } `json:"album"` Artists []struct { - Name string `json:"name"` + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Picture string `json:"picture"` } `json:"artists"` Artist struct { - Name string `json:"name"` + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Picture string `json:"picture"` } `json:"artist"` MediaMetadata struct { Tags []string `json:"tags"` } `json:"mediaMetadata"` + URL string `json:"url"` } type TidalAPIResponseV2 struct { @@ -100,6 +116,97 @@ type MPD struct { } `xml:"Period"` } +type tidalPublicArtist struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Picture string `json:"picture"` +} + +type tidalPublicAlbum struct { + ID int64 `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Cover string `json:"cover"` + ReleaseDate string `json:"releaseDate"` + URL string `json:"url"` + NumberOfTracks int `json:"numberOfTracks"` + Explicit bool `json:"explicit"` + Artists []tidalPublicArtist `json:"artists"` +} + +type tidalPublicAlbumPage struct { + Rows []struct { + Modules []struct { + Type string `json:"type"` + Album tidalPublicAlbum `json:"album"` + PagedList struct { + DataAPIPath string `json:"dataApiPath"` + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []struct { + Item TidalTrack `json:"item"` + Type string `json:"type"` + } `json:"items"` + } `json:"pagedList"` + } `json:"modules"` + } `json:"rows"` +} + +type tidalPublicArtistPage struct { + Rows []struct { + Modules []struct { + Type string `json:"type"` + Artist struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Picture string `json:"picture"` + } `json:"artist"` + PagedList struct { + DataAPIPath string `json:"dataApiPath"` + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []tidalPublicAlbum `json:"items"` + } `json:"pagedList"` + } `json:"modules"` + } `json:"rows"` +} + +type tidalPublicArtistAlbumsPage struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []tidalPublicAlbum `json:"items"` +} + +type tidalPublicPlaylist struct { + UUID string `json:"uuid"` + Title string `json:"title"` + Description string `json:"description"` + Type string `json:"type"` + URL string `json:"url"` + Image string `json:"image"` + SquareImage string `json:"squareImage"` + NumberOfTracks int `json:"numberOfTracks"` + Creator struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"creator"` +} + +type tidalPublicPlaylistItemsPage struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []struct { + Item TidalTrack `json:"item"` + Type string `json:"type"` + } `json:"items"` +} + func NewTidalDownloader() *TidalDownloader { tidalDownloaderOnce.Do(func() { globalTidalDownloader = &TidalDownloader{ @@ -114,6 +221,408 @@ func NewTidalDownloader() *TidalDownloader { return globalTidalDownloader } +func tidalPrefixedID(id string) string { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return "" + } + return "tidal:" + trimmed +} + +func tidalPrefixedNumericID(id int64) string { + if id <= 0 { + return "" + } + return fmt.Sprintf("tidal:%d", id) +} + +func tidalImageURL(imageID, size string) string { + normalizedID := strings.TrimSpace(imageID) + if normalizedID == "" || strings.TrimSpace(size) == "" { + return "" + } + return fmt.Sprintf( + "%s/images/%s/%s.jpg", + tidalResourceBaseURL, + strings.ReplaceAll(normalizedID, "-", "/"), + size, + ) +} + +func tidalFirstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func tidalJoinArtistNames(artists []tidalPublicArtist) string { + if len(artists) == 0 { + return "" + } + + names := make([]string, 0, len(artists)) + for _, artist := range artists { + if trimmed := strings.TrimSpace(artist.Name); trimmed != "" { + names = append(names, trimmed) + } + } + return strings.Join(names, ", ") +} + +func tidalTrackArtistsDisplay(track *TidalTrack) string { + if track == nil { + return "" + } + + if len(track.Artists) > 0 { + names := make([]string, 0, len(track.Artists)) + for _, artist := range track.Artists { + if trimmed := strings.TrimSpace(artist.Name); trimmed != "" { + names = append(names, trimmed) + } + } + if len(names) > 0 { + return strings.Join(names, ", ") + } + } + + return strings.TrimSpace(track.Artist.Name) +} + +func tidalAlbumArtistsDisplay(album *tidalPublicAlbum) string { + if album == nil { + return "" + } + return tidalJoinArtistNames(album.Artists) +} + +func tidalTrackExternalURL(track *TidalTrack) string { + if track == nil { + return "" + } + if trimmed := strings.TrimSpace(track.URL); trimmed != "" { + return strings.Replace(trimmed, "http://", "https://", 1) + } + if track.ID > 0 { + return fmt.Sprintf("https://tidal.com/browse/track/%d", track.ID) + } + return "" +} + +func tidalAlbumExternalURL(album *tidalPublicAlbum) string { + if album == nil { + return "" + } + if trimmed := strings.TrimSpace(album.URL); trimmed != "" { + return strings.Replace(trimmed, "http://", "https://", 1) + } + if album.ID > 0 { + return fmt.Sprintf("https://tidal.com/browse/album/%d", album.ID) + } + return "" +} + +func tidalTrackToTrackMetadata(track *TidalTrack) TrackMetadata { + if track == nil { + return TrackMetadata{} + } + + artistID := tidalPrefixedNumericID(track.Artist.ID) + if artistID == "" && len(track.Artists) > 0 { + artistID = tidalPrefixedNumericID(track.Artists[0].ID) + } + + return TrackMetadata{ + SpotifyID: tidalPrefixedNumericID(track.ID), + Artists: tidalTrackArtistsDisplay(track), + Name: strings.TrimSpace(track.Title), + AlbumName: strings.TrimSpace(track.Album.Title), + AlbumArtist: strings.TrimSpace(track.Artist.Name), + DurationMS: track.Duration * 1000, + Images: tidalImageURL(track.Album.Cover, "1280x1280"), + ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate), + TrackNumber: track.TrackNumber, + DiscNumber: track.VolumeNumber, + ExternalURL: tidalTrackExternalURL(track), + ISRC: strings.TrimSpace(track.ISRC), + AlbumID: tidalPrefixedNumericID(track.Album.ID), + ArtistID: artistID, + } +} + +func tidalTrackToAlbumTrackMetadata(track *TidalTrack) AlbumTrackMetadata { + if track == nil { + return AlbumTrackMetadata{} + } + + return AlbumTrackMetadata{ + SpotifyID: tidalPrefixedNumericID(track.ID), + Artists: tidalTrackArtistsDisplay(track), + Name: strings.TrimSpace(track.Title), + AlbumName: strings.TrimSpace(track.Album.Title), + AlbumArtist: strings.TrimSpace(track.Artist.Name), + DurationMS: track.Duration * 1000, + Images: tidalImageURL(track.Album.Cover, "1280x1280"), + ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate), + TrackNumber: track.TrackNumber, + DiscNumber: track.VolumeNumber, + ExternalURL: tidalTrackExternalURL(track), + ISRC: strings.TrimSpace(track.ISRC), + AlbumID: tidalPrefixedNumericID(track.Album.ID), + AlbumURL: strings.Replace(strings.TrimSpace(track.Album.URL), "http://", "https://", 1), + } +} + +func tidalAlbumToAlbumInfo(album *tidalPublicAlbum) AlbumInfoMetadata { + if album == nil { + return AlbumInfoMetadata{} + } + + artistID := "" + if len(album.Artists) > 0 { + artistID = tidalPrefixedNumericID(album.Artists[0].ID) + } + + return AlbumInfoMetadata{ + TotalTracks: album.NumberOfTracks, + Name: strings.TrimSpace(album.Title), + ReleaseDate: strings.TrimSpace(album.ReleaseDate), + Artists: tidalAlbumArtistsDisplay(album), + ArtistId: artistID, + Images: tidalImageURL(album.Cover, "1280x1280"), + } +} + +func tidalAlbumToArtistAlbum(album *tidalPublicAlbum) ArtistAlbumMetadata { + if album == nil { + return ArtistAlbumMetadata{} + } + + 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)), + Artists: tidalAlbumArtistsDisplay(album), + } +} + +func tidalPlaylistOwnerName(playlist *tidalPublicPlaylist) string { + if playlist == nil { + return "" + } + if trimmed := strings.TrimSpace(playlist.Creator.Name); trimmed != "" { + return trimmed + } + if strings.EqualFold(strings.TrimSpace(playlist.Type), "ARTIST") { + return "Artist" + } + return "TIDAL" +} + +func tidalBuildMetadataURL(path string, extraQuery url.Values) string { + trimmedPath := strings.TrimLeft(strings.TrimSpace(path), "/") + if trimmedPath == "" { + return tidalPublicAPIBaseURL + } + + baseURL, err := url.Parse(tidalPublicAPIBaseURL + "/" + trimmedPath) + if err != nil { + return tidalPublicAPIBaseURL + "/" + trimmedPath + } + + query := baseURL.Query() + query.Set("countryCode", tidalCountryCode) + query.Set("locale", tidalLocale) + query.Set("deviceType", tidalDeviceType) + for key, values := range extraQuery { + query.Del(key) + for _, value := range values { + query.Add(key, value) + } + } + baseURL.RawQuery = query.Encode() + return baseURL.String() +} + +func (t *TidalDownloader) getTidalMetadataJSON(requestURL string, target interface{}) error { + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("x-tidal-token", tidalPublicToken) + + resp, err := DoRequestWithUserAgent(t.client, req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("tidal metadata request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) + } + + return json.NewDecoder(resp.Body).Decode(target) +} + +func (t *TidalDownloader) getPublicTrack(resourceID string) (*TidalTrack, error) { + trackID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64) + if err != nil || trackID <= 0 { + return nil, fmt.Errorf("invalid tidal track ID: %s", resourceID) + } + + requestURL := tidalBuildMetadataURL(fmt.Sprintf("tracks/%d", trackID), nil) + var track TidalTrack + if err := t.getTidalMetadataJSON(requestURL, &track); err != nil { + return nil, err + } + return &track, nil +} + +func (t *TidalDownloader) getAlbumPage(resourceID string) (*tidalPublicAlbumPage, error) { + albumID := strings.TrimSpace(resourceID) + if albumID == "" { + return nil, fmt.Errorf("invalid tidal album ID") + } + + requestURL := tidalBuildMetadataURL("pages/album", url.Values{"albumId": {albumID}}) + var page tidalPublicAlbumPage + if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { + return nil, err + } + return &page, nil +} + +func (t *TidalDownloader) getArtistPage(resourceID string) (*tidalPublicArtistPage, error) { + artistID := strings.TrimSpace(resourceID) + if artistID == "" { + return nil, fmt.Errorf("invalid tidal artist ID") + } + + requestURL := tidalBuildMetadataURL("pages/artist", url.Values{"artistId": {artistID}}) + var page tidalPublicArtistPage + if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { + return nil, err + } + return &page, nil +} + +func (t *TidalDownloader) getArtistAlbumsPage(dataAPIPath string, offset, limit int) (*tidalPublicArtistAlbumsPage, error) { + extraQuery := url.Values{} + if offset >= 0 { + extraQuery.Set("offset", strconv.Itoa(offset)) + } + if limit > 0 { + extraQuery.Set("limit", strconv.Itoa(limit)) + } + + requestURL := tidalBuildMetadataURL(dataAPIPath, extraQuery) + var page tidalPublicArtistAlbumsPage + if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { + return nil, err + } + return &page, nil +} + +func (t *TidalDownloader) getPlaylist(resourceID string) (*tidalPublicPlaylist, error) { + playlistID := strings.TrimSpace(resourceID) + if playlistID == "" { + return nil, fmt.Errorf("invalid tidal playlist ID") + } + + requestURL := tidalBuildMetadataURL("playlists/"+url.PathEscape(playlistID), nil) + var playlist tidalPublicPlaylist + if err := t.getTidalMetadataJSON(requestURL, &playlist); err != nil { + return nil, err + } + return &playlist, nil +} + +func (t *TidalDownloader) getPlaylistItemsPage(resourceID string, offset, limit int) (*tidalPublicPlaylistItemsPage, error) { + playlistID := strings.TrimSpace(resourceID) + if playlistID == "" { + return nil, fmt.Errorf("invalid tidal playlist ID") + } + + requestURL := tidalBuildMetadataURL( + "playlists/"+url.PathEscape(playlistID)+"/items", + url.Values{ + "offset": {strconv.Itoa(offset)}, + "limit": {strconv.Itoa(limit)}, + }, + ) + var page tidalPublicPlaylistItemsPage + if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { + return nil, err + } + return &page, nil +} + +func findTidalAlbumPageModule(page *tidalPublicAlbumPage, moduleType string) *struct { + Type string `json:"type"` + Album tidalPublicAlbum `json:"album"` + PagedList struct { + DataAPIPath string `json:"dataApiPath"` + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []struct { + Item TidalTrack `json:"item"` + Type string `json:"type"` + } `json:"items"` + } `json:"pagedList"` +} { + if page == nil { + return nil + } + for rowIndex := range page.Rows { + for moduleIndex := range page.Rows[rowIndex].Modules { + module := &page.Rows[rowIndex].Modules[moduleIndex] + if module.Type == moduleType { + return module + } + } + } + return nil +} + +func findTidalArtistPageModule(page *tidalPublicArtistPage, moduleType string) *struct { + Type string `json:"type"` + Artist struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Picture string `json:"picture"` + } `json:"artist"` + PagedList struct { + DataAPIPath string `json:"dataApiPath"` + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []tidalPublicAlbum `json:"items"` + } `json:"pagedList"` +} { + if page == nil { + return nil + } + for rowIndex := range page.Rows { + for moduleIndex := range page.Rows[rowIndex].Modules { + module := &page.Rows[rowIndex].Modules[moduleIndex] + if module.Type == moduleType { + return module + } + } + } + return nil +} + func (t *TidalDownloader) GetAvailableAPIs() []string { return []string{ "https://tidal-api.binimum.org", @@ -203,6 +712,146 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (* return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") } +func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) { + track, err := t.getPublicTrack(resourceID) + if err != nil { + return nil, err + } + return &TrackResponse{Track: tidalTrackToTrackMetadata(track)}, nil +} + +func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePayload, error) { + page, err := t.getAlbumPage(resourceID) + if err != nil { + return nil, err + } + + headerModule := findTidalAlbumPageModule(page, "ALBUM_HEADER") + itemsModule := findTidalAlbumPageModule(page, "ALBUM_ITEMS") + if headerModule == nil { + return nil, fmt.Errorf("tidal album page missing album header") + } + if itemsModule == nil { + return nil, fmt.Errorf("tidal album page missing track list") + } + + tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items)) + for _, item := range itemsModule.PagedList.Items { + track := item.Item + if track.Album.ID == 0 { + track.Album.ID = headerModule.Album.ID + track.Album.Title = headerModule.Album.Title + track.Album.Cover = headerModule.Album.Cover + track.Album.ReleaseDate = headerModule.Album.ReleaseDate + track.Album.URL = headerModule.Album.URL + } + tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track)) + } + + return &AlbumResponsePayload{ + AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album), + TrackList: tracks, + }, nil +} + +func (t *TidalDownloader) GetPlaylistMetadata(resourceID string) (*PlaylistResponsePayload, error) { + playlist, err := t.getPlaylist(resourceID) + if err != nil { + return nil, err + } + + const pageSize = 50 + offset := 0 + totalTracks := playlist.NumberOfTracks + tracks := make([]AlbumTrackMetadata, 0, totalTracks) + + for { + page, pageErr := t.getPlaylistItemsPage(resourceID, offset, pageSize) + if pageErr != nil { + return nil, pageErr + } + if totalTracks == 0 && page.TotalNumberOfItems > 0 { + totalTracks = page.TotalNumberOfItems + } + + for _, item := range page.Items { + if item.Type != "track" { + continue + } + tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&item.Item)) + } + + if len(page.Items) == 0 || offset+len(page.Items) >= totalTracks || len(page.Items) < pageSize { + break + } + offset += len(page.Items) + } + + var info PlaylistInfoMetadata + info.Tracks.Total = totalTracks + info.Name = strings.TrimSpace(playlist.Title) + info.Images = tidalImageURL(tidalFirstNonEmpty(playlist.SquareImage, playlist.Image), "1280x1280") + info.Owner.DisplayName = tidalPlaylistOwnerName(playlist) + info.Owner.Name = strings.TrimSpace(playlist.Title) + info.Owner.Images = info.Images + + return &PlaylistResponsePayload{ + PlaylistInfo: info, + TrackList: tracks, + }, nil +} + +func (t *TidalDownloader) GetArtistMetadata(resourceID string) (*ArtistResponsePayload, error) { + page, err := t.getArtistPage(resourceID) + if err != nil { + return nil, err + } + + headerModule := findTidalArtistPageModule(page, "ARTIST_HEADER") + albumsModule := findTidalArtistPageModule(page, "ALBUM_LIST") + if headerModule == nil { + return nil, fmt.Errorf("tidal artist page missing artist header") + } + if albumsModule == nil { + return nil, fmt.Errorf("tidal artist page missing albums list") + } + + albums := make([]ArtistAlbumMetadata, 0, albumsModule.PagedList.TotalNumberOfItems) + for _, album := range albumsModule.PagedList.Items { + albums = append(albums, tidalAlbumToArtistAlbum(&album)) + } + + 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 _, album := range albumsPage.Items { + albums = append(albums, tidalAlbumToArtistAlbum(&album)) + } + + if len(albumsPage.Items) == 0 || offset+len(albumsPage.Items) >= albumsPage.TotalNumberOfItems { + break + } + offset += len(albumsPage.Items) + } + + return &ArtistResponsePayload{ + ArtistInfo: ArtistInfoMetadata{ + ID: tidalPrefixedNumericID(headerModule.Artist.ID), + Name: strings.TrimSpace(headerModule.Artist.Name), + Images: tidalImageURL(headerModule.Artist.Picture, "750x750"), + }, + Albums: albums, + }, nil +} + type TidalDownloadInfo struct { URL string BitDepth int @@ -1062,20 +1711,18 @@ func isLatinScript(s string) bool { return true } -func tidalTrackArtistsDisplay(track *TidalTrack) string { - if track == nil { - return "" +func parseTidalRequestTrackID(raw string) (int64, bool) { + trimmed := strings.TrimSpace(raw) + trimmed = strings.TrimPrefix(trimmed, "tidal:") + if trimmed == "" { + return 0, false } - tidalArtist := track.Artist.Name - if len(track.Artists) > 0 { - var artistNames []string - for _, a := range track.Artists { - artistNames = append(artistNames, a.Name) - } - tidalArtist = strings.Join(artistNames, ", ") + trackID, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil || trackID <= 0 { + return 0, false } - return tidalArtist + return trackID, true } func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloader, logPrefix string) (*TidalTrack, error) { @@ -1091,8 +1738,9 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade var gotTidalID bool if req.TidalID != "" { - GoLog("[%s] Using Tidal ID from Odesli enrichment: %s\n", logPrefix, req.TidalID) - if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { + GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID) + if parsedTrackID, ok := parseTidalRequestTrackID(req.TidalID); ok { + trackID = parsedTrackID gotTidalID = true } } @@ -1113,7 +1761,8 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade return } if availability.TidalID != "" { - if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { + if parsedTrackID, ok := parseTidalRequestTrackID(availability.TidalID); ok { + trackID = parsedTrackID GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID) gotTidalID = true return diff --git a/go_backend/tidal_test.go b/go_backend/tidal_test.go new file mode 100644 index 00000000..647ebaf0 --- /dev/null +++ b/go_backend/tidal_test.go @@ -0,0 +1,181 @@ +package gobackend + +import "testing" + +func TestParseTidalURL(t *testing.T) { + tests := []struct { + name string + input string + wantType string + wantID string + expectErr bool + }{ + { + name: "track url", + input: "https://tidal.com/track/77616174", + wantType: "track", + wantID: "77616174", + }, + { + name: "browse album url", + input: "https://listen.tidal.com/browse/album/77616169", + wantType: "album", + wantID: "77616169", + }, + { + name: "artist url", + input: "https://www.tidal.com/artist/3852143", + wantType: "artist", + wantID: "3852143", + }, + { + name: "playlist url", + input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b", + wantType: "playlist", + wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b", + }, + { + name: "unsupported host", + input: "https://example.com/track/123", + expectErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gotType, gotID, err := parseTidalURL(test.input) + if test.expectErr { + if err == nil { + t.Fatalf("expected error, got none") + } + return + } + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if gotType != test.wantType || gotID != test.wantID { + t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID) + } + }) + } +} + +func TestParseTidalRequestTrackID(t *testing.T) { + tests := []struct { + input string + want int64 + ok bool + }{ + {input: "40681594", want: 40681594, ok: true}, + {input: "tidal:40681594", want: 40681594, ok: true}, + {input: " tidal:40681594 ", want: 40681594, ok: true}, + {input: "", want: 0, ok: false}, + {input: "tidal:not-a-number", want: 0, ok: false}, + } + + for _, test := range tests { + got, ok := parseTidalRequestTrackID(test.input) + if got != test.want || ok != test.ok { + t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok) + } + } +} + +func TestTidalImageURL(t *testing.T) { + got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280") + want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg" + if got != want { + t.Fatalf("tidalImageURL() = %q, want %q", got, want) + } +} + +func TestTidalTrackToTrackMetadata(t *testing.T) { + track := &TidalTrack{ + ID: 77616174, + Title: "Bruckner: Symphony No. 5", + ISRC: "GBUM71507433", + Duration: 1172, + TrackNumber: 5, + VolumeNumber: 1, + URL: "http://www.tidal.com/track/77616174", + } + track.Artist.ID = 3852143 + track.Artist.Name = "Staatskapelle Berlin" + track.Artists = []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Picture string `json:"picture"` + }{ + {ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"}, + {ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"}, + } + track.Album.ID = 77616169 + track.Album.Title = "Bruckner: Symphonies 4-9" + track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3" + track.Album.ReleaseDate = "2016-02-26" + + got := tidalTrackToTrackMetadata(track) + if got.SpotifyID != "tidal:77616174" { + t.Fatalf("unexpected track ID: %q", got.SpotifyID) + } + if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" { + t.Fatalf("unexpected artists: %q", got.Artists) + } + if got.AlbumID != "tidal:77616169" { + t.Fatalf("unexpected album ID: %q", got.AlbumID) + } + if got.ArtistID != "tidal:3852143" { + t.Fatalf("unexpected artist ID: %q", got.ArtistID) + } + if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" { + t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL) + } +} + +func TestTidalAlbumToArtistAlbum(t *testing.T) { + album := &tidalPublicAlbum{ + ID: 77616169, + Title: "Bruckner: Symphonies 4-9", + Type: "ALBUM", + Cover: "fc18a64b-d76b-4582-962a-224cb05193f3", + ReleaseDate: "2016-02-26", + NumberOfTracks: 23, + Artists: []tidalPublicArtist{ + {ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"}, + {ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"}, + }, + } + + got := tidalAlbumToArtistAlbum(album) + if got.ID != "tidal:77616169" { + t.Fatalf("unexpected album ID: %q", got.ID) + } + if got.AlbumType != "album" { + t.Fatalf("unexpected album type: %q", got.AlbumType) + } + if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" { + t.Fatalf("unexpected artists: %q", got.Artists) + } + if got.Images == "" { + t.Fatalf("expected image URL, got empty string") + } +} + +func TestTidalPlaylistOwnerName(t *testing.T) { + editorial := &tidalPublicPlaylist{Type: "EDITORIAL"} + if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" { + t.Fatalf("unexpected editorial owner: %q", got) + } + + artist := &tidalPublicPlaylist{Type: "ARTIST"} + if got := tidalPlaylistOwnerName(artist); got != "Artist" { + t.Fatalf("unexpected artist owner: %q", got) + } + + user := &tidalPublicPlaylist{} + user.Creator.Name = "djtest" + if got := tidalPlaylistOwnerName(user); got != "djtest" { + t.Fatalf("unexpected creator owner: %q", got) + } +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 320efb5a..d72d27f2 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -383,6 +383,22 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "getQobuzMetadata": + let args = call.arguments as! [String: Any] + let resourceType = args["resource_type"] as! String + let resourceId = args["resource_id"] as! String + let response = GobackendGetQobuzMetadata(resourceType, resourceId, &error) + if let error = error { throw error } + return response + + case "getTidalMetadata": + let args = call.arguments as! [String: Any] + let resourceType = args["resource_type"] as! String + let resourceId = args["resource_id"] as! String + let response = GobackendGetTidalMetadata(resourceType, resourceId, &error) + if let error = error { throw error } + return response + case "parseDeezerUrl": let args = call.arguments as! [String: Any] let url = args["url"] as! String @@ -390,6 +406,13 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "parseQobuzUrl": + let args = call.arguments as! [String: Any] + let url = args["url"] as! String + let response = GobackendParseQobuzURLExport(url, &error) + if let error = error { throw error } + return response + case "parseTidalUrl": let args = call.arguments as! [String: Any] let url = args["url"] as! String diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 7dc37dd3..9df46a5b 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -3605,10 +3605,26 @@ class DownloadQueueNotifier extends Notifier { ? trackToDownload.discNumber! : 1; + String payloadSpotifyId = trackToDownload.id; + String payloadQobuzId = ''; + String payloadTidalId = ''; + if (trackToDownload.id.startsWith('qobuz:')) { + payloadQobuzId = trackToDownload.id.substring(6); + if (item.service == 'qobuz') { + payloadSpotifyId = ''; + } + } + if (trackToDownload.id.startsWith('tidal:')) { + payloadTidalId = trackToDownload.id.substring(6); + if (item.service == 'tidal') { + payloadSpotifyId = ''; + } + } + final payload = DownloadRequestPayload( isrc: trackToDownload.isrc ?? '', service: item.service, - spotifyId: trackToDownload.id, + spotifyId: payloadSpotifyId, trackName: trackToDownload.name, artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, @@ -3632,6 +3648,8 @@ class DownloadQueueNotifier extends Notifier { genre: genre ?? '', label: label ?? '', copyright: copyright ?? '', + qobuzId: payloadQobuzId, + tidalId: payloadTidalId, deezerId: deezerTrackId ?? '', lyricsMode: settings.lyricsMode, storageMode: storageMode, diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 1c3974c2..2a36676c 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -18,7 +18,7 @@ class TrackState { final String? artistName; final String? coverUrl; final String? headerImageUrl; // Artist header image for background - final int? monthlyListeners; // Artist monthly listeners + final int? monthlyListeners; final List? artistAlbums; // For artist page final List? artistTopTracks; // Artist's popular tracks final List? searchArtists; // For search results @@ -384,6 +384,72 @@ class TrackNotifier extends Notifier { return; } + if (url.contains('qobuz.com') || url.startsWith('qobuzapp://')) { + _log.i('Detected Qobuz URL, parsing...'); + final parsed = await PlatformBridge.parseQobuzUrl(url); + if (!_isRequestValid(requestId)) return; + + final type = parsed['type'] as String; + final id = parsed['id'] as String; + + final metadata = await PlatformBridge.getQobuzMetadata(type, id); + if (!_isRequestValid(requestId)) return; + + if (type == 'track') { + final trackData = metadata['track'] as Map; + final track = _parseTrack(trackData); + state = TrackState( + tracks: [track], + isLoading: false, + coverUrl: track.coverUrl, + ); + } else if (type == 'album') { + final albumInfo = metadata['album_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + state = TrackState( + tracks: tracks, + isLoading: false, + albumId: 'qobuz:$id', + albumName: albumInfo['name'] as String?, + coverUrl: albumInfo['images'] as String?, + ); + _preWarmCacheForTracks(tracks); + } else if (type == 'playlist') { + final playlistInfo = + metadata['playlist_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + final owner = playlistInfo['owner'] as Map?; + state = TrackState( + tracks: tracks, + isLoading: false, + playlistName: owner?['name'] as String?, + coverUrl: owner?['images'] as String?, + ); + _preWarmCacheForTracks(tracks); + } else if (type == 'artist') { + final artistInfo = metadata['artist_info'] as Map; + final albumsList = metadata['albums'] as List; + final albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + state = TrackState( + tracks: [], + isLoading: false, + artistId: artistInfo['id'] as String?, + artistName: artistInfo['name'] as String?, + coverUrl: artistInfo['images'] as String?, + artistAlbums: albums, + ); + } + return; + } + if (url.contains('tidal.com')) { _log.i('Detected Tidal URL, parsing...'); final parsed = await PlatformBridge.parseTidalUrl(url); @@ -392,68 +458,61 @@ class TrackNotifier extends Notifier { final type = parsed['type'] as String; final id = parsed['id'] as String; - _log.i('Tidal URL parsed: type=$type, id=$id'); + final metadata = await PlatformBridge.getTidalMetadata(type, id); + if (!_isRequestValid(requestId)) return; - // For track URLs, convert to Spotify/Deezer and fetch metadata from there if (type == 'track') { - try { - _log.i('Converting Tidal track to Spotify/Deezer via SongLink...'); - final conversion = await PlatformBridge.convertTidalToSpotifyDeezer( - url, - ); - if (!_isRequestValid(requestId)) return; - - final spotifyUrl = conversion['spotify_url'] as String?; - final deezerUrl = conversion['deezer_url'] as String?; - - if (spotifyUrl != null && spotifyUrl.isNotEmpty) { - _log.i('Found Spotify URL: $spotifyUrl, fetching metadata...'); - final metadata = - await PlatformBridge.getSpotifyMetadataWithFallback( - spotifyUrl, - ); - if (!_isRequestValid(requestId)) return; - - final trackData = metadata['track'] as Map; - final track = _parseTrack(trackData); - state = TrackState( - tracks: [track], - isLoading: false, - coverUrl: track.coverUrl, - ); - return; - } else if (deezerUrl != null && deezerUrl.isNotEmpty) { - _log.i('Found Deezer URL: $deezerUrl, fetching metadata...'); - final deezerParsed = await PlatformBridge.parseDeezerUrl( - deezerUrl, - ); - final metadata = await PlatformBridge.getDeezerMetadata( - 'track', - deezerParsed['id'] as String, - ); - if (!_isRequestValid(requestId)) return; - - final trackData = metadata['track'] as Map; - final track = _parseTrack(trackData); - state = TrackState( - tracks: [track], - isLoading: false, - coverUrl: track.coverUrl, - ); - return; - } - } catch (e) { - _log.w('Failed to convert Tidal URL via SongLink: $e'); - } + final trackData = metadata['track'] as Map; + final track = _parseTrack(trackData); + state = TrackState( + tracks: [track], + isLoading: false, + coverUrl: track.coverUrl, + ); + } else if (type == 'album') { + final albumInfo = metadata['album_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + state = TrackState( + tracks: tracks, + isLoading: false, + albumId: 'tidal:$id', + albumName: albumInfo['name'] as String?, + coverUrl: albumInfo['images'] as String?, + ); + _preWarmCacheForTracks(tracks); + } else if (type == 'playlist') { + final playlistInfo = + metadata['playlist_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + final owner = playlistInfo['owner'] as Map?; + state = TrackState( + tracks: tracks, + isLoading: false, + playlistName: owner?['name'] as String?, + coverUrl: owner?['images'] as String?, + ); + _preWarmCacheForTracks(tracks); + } else if (type == 'artist') { + final artistInfo = metadata['artist_info'] as Map; + final albumsList = metadata['albums'] as List; + final albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + state = TrackState( + tracks: [], + isLoading: false, + artistId: artistInfo['id'] as String?, + artistName: artistInfo['name'] as String?, + coverUrl: artistInfo['images'] as String?, + artistAlbums: albums, + ); } - - // For album/artist/playlist, not yet supported - state = TrackState( - isLoading: false, - error: - 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.', - hasSearchText: state.hasSearchText, - ); return; } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 2352cfac..f498d834 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -84,7 +84,12 @@ class _AlbumScreenState extends ConsumerState { // Use extensionId if available, otherwise detect from albumId prefix final providerId = widget.extensionId ?? - (widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'); + (() { + if (widget.albumId.startsWith('deezer:')) return 'deezer'; + if (widget.albumId.startsWith('qobuz:')) return 'qobuz'; + if (widget.albumId.startsWith('tidal:')) return 'tidal'; + return 'spotify'; + })(); ref .read(recentAccessProvider.notifier) .recordAlbumAccess( @@ -175,6 +180,12 @@ class _AlbumScreenState extends ConsumerState { 'album', deezerAlbumId, ); + } else if (widget.albumId.startsWith('qobuz:')) { + final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', ''); + metadata = await PlatformBridge.getQobuzMetadata('album', qobuzAlbumId); + } else if (widget.albumId.startsWith('tidal:')) { + final tidalAlbumId = widget.albumId.replaceFirst('tidal:', ''); + metadata = await PlatformBridge.getTidalMetadata('album', tidalAlbumId); } else { final url = 'https://open.spotify.com/album/${widget.albumId}'; metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); @@ -619,7 +630,9 @@ class _AlbumScreenState extends ConsumerState { size: 22, color: allLoved ? Colors.redAccent : Colors.white, ), - tooltip: allLoved ? context.l10n.trackOptionRemoveFromLoved : context.l10n.tooltipLoveAll, + tooltip: allLoved + ? context.l10n.trackOptionRemoveFromLoved + : context.l10n.tooltipLoveAll, padding: EdgeInsets.zero, ), ); @@ -678,9 +691,7 @@ class _AlbumScreenState extends ConsumerState { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - context.l10n.snackbarAddedTracksToLoved(addedCount), - ), + content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)), ), ); } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 06fc5063..31ba546e 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -153,7 +153,12 @@ class _ArtistScreenState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { final providerId = widget.extensionId ?? - (widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify'); + (() { + if (widget.artistId.startsWith('deezer:')) return 'deezer'; + if (widget.artistId.startsWith('qobuz:')) return 'qobuz'; + if (widget.artistId.startsWith('tidal:')) return 'tidal'; + return 'spotify'; + })(); ref .read(recentAccessProvider.notifier) .recordArtistAccess( @@ -230,6 +235,30 @@ class _ArtistScreenState extends ConsumerState { albums = albumsList .map((a) => _parseArtistAlbum(a as Map)) .toList(); + } else if (widget.artistId.startsWith('qobuz:')) { + final qobuzArtistId = widget.artistId.replaceFirst('qobuz:', ''); + final metadata = await PlatformBridge.getQobuzMetadata( + 'artist', + qobuzArtistId, + ); + final albumsList = metadata['albums'] as List; + albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + final artistInfo = metadata['artist_info'] as Map?; + headerImage = artistInfo?['images'] as String?; + } else if (widget.artistId.startsWith('tidal:')) { + final tidalArtistId = widget.artistId.replaceFirst('tidal:', ''); + final metadata = await PlatformBridge.getTidalMetadata( + 'artist', + tidalArtistId, + ); + final albumsList = metadata['albums'] as List; + albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + final artistInfo = metadata['artist_info'] as Map?; + headerImage = artistInfo?['images'] as String?; } else { final url = 'https://open.spotify.com/artist/${widget.artistId}'; final result = await PlatformBridge.handleURLWithExtension(url); @@ -961,6 +990,24 @@ class _ArtistScreenState extends ConsumerState { .map((t) => _parseTrackFromDeezer(t as Map, album)) .toList(); } + } else if (album.id.startsWith('qobuz:')) { + final qobuzId = album.id.replaceFirst('qobuz:', ''); + final metadata = await PlatformBridge.getQobuzMetadata('album', qobuzId); + if (metadata['track_list'] != null) { + final tracksList = metadata['track_list'] as List; + return tracksList + .map((t) => _parseTrack(t as Map, album: album)) + .toList(); + } + } else if (album.id.startsWith('tidal:')) { + final tidalId = album.id.replaceFirst('tidal:', ''); + final metadata = await PlatformBridge.getTidalMetadata('album', tidalId); + if (metadata['track_list'] != null) { + final tracksList = metadata['track_list'] as List; + return tracksList + .map((t) => _parseTrack(t as Map, album: album)) + .toList(); + } } else { final url = 'https://open.spotify.com/album/${album.id}'; final result = await PlatformBridge.handleURLWithExtension(url); diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index a87676a1..b76c0886 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -65,16 +65,20 @@ class _PlaylistScreenState extends ConsumerState { }); try { - // Extract numeric ID from "deezer:123" format String playlistId = widget.playlistId!; + late final Map result; if (playlistId.startsWith('deezer:')) { playlistId = playlistId.substring(7); + result = await PlatformBridge.getDeezerMetadata('playlist', playlistId); + } else if (playlistId.startsWith('qobuz:')) { + playlistId = playlistId.substring(6); + result = await PlatformBridge.getQobuzMetadata('playlist', playlistId); + } else if (playlistId.startsWith('tidal:')) { + playlistId = playlistId.substring(6); + result = await PlatformBridge.getTidalMetadata('playlist', playlistId); + } else { + result = await PlatformBridge.getDeezerMetadata('playlist', playlistId); } - - final result = await PlatformBridge.getDeezerMetadata( - 'playlist', - playlistId, - ); if (!mounted) return; // Go backend returns 'track_list' not 'tracks' @@ -416,7 +420,12 @@ class _PlaylistScreenState extends ConsumerState { onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addToQueue(track, service, qualityOverride: quality, playlistName: widget.playlistName); + .addToQueue( + track, + service, + qualityOverride: quality, + playlistName: widget.playlistName, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.snackbarAddedToQueue(track.name)), @@ -427,7 +436,11 @@ class _PlaylistScreenState extends ConsumerState { } else { ref .read(downloadQueueProvider.notifier) - .addToQueue(track, settings.defaultService, playlistName: widget.playlistName); + .addToQueue( + track, + settings.defaultService, + playlistName: widget.playlistName, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); @@ -482,7 +495,9 @@ class _PlaylistScreenState extends ConsumerState { size: 22, color: allLoved ? Colors.redAccent : Colors.white, ), - tooltip: allLoved ? context.l10n.trackOptionRemoveFromLoved : context.l10n.tooltipLoveAll, + tooltip: allLoved + ? context.l10n.trackOptionRemoveFromLoved + : context.l10n.tooltipLoveAll, padding: EdgeInsets.zero, ), ); @@ -570,9 +585,7 @@ class _PlaylistScreenState extends ConsumerState { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - context.l10n.snackbarAddedTracksToLoved(addedCount), - ), + content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)), ), ); } @@ -594,7 +607,12 @@ class _PlaylistScreenState extends ConsumerState { onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, service, qualityOverride: quality, playlistName: widget.playlistName); + .addMultipleToQueue( + tracks, + service, + qualityOverride: quality, + playlistName: widget.playlistName, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -607,7 +625,11 @@ class _PlaylistScreenState extends ConsumerState { } else { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, settings.defaultService, playlistName: widget.playlistName); + .addMultipleToQueue( + tracks, + settings.defaultService, + playlistName: widget.playlistName, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 8ea1dddc..f3601165 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -535,11 +535,48 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> getQobuzMetadata( + String resourceType, + String resourceId, + ) async { + final result = await _channel.invokeMethod('getQobuzMetadata', { + 'resource_type': resourceType, + 'resource_id': resourceId, + }); + if (result == null) { + throw Exception( + 'getQobuzMetadata returned null for $resourceType:$resourceId', + ); + } + return jsonDecode(result as String) as Map; + } + + static Future> parseQobuzUrl(String url) async { + final result = await _channel.invokeMethod('parseQobuzUrl', {'url': url}); + return jsonDecode(result as String) as Map; + } + static Future> parseTidalUrl(String url) async { final result = await _channel.invokeMethod('parseTidalUrl', {'url': url}); return jsonDecode(result as String) as Map; } + static Future> getTidalMetadata( + String resourceType, + String resourceId, + ) async { + final result = await _channel.invokeMethod('getTidalMetadata', { + 'resource_type': resourceType, + 'resource_id': resourceId, + }); + if (result == null) { + throw Exception( + 'getTidalMetadata returned null for $resourceType:$resourceId', + ); + } + return jsonDecode(result as String) as Map; + } + static Future> convertTidalToSpotifyDeezer( String tidalUrl, ) async { @@ -1193,7 +1230,9 @@ class PlatformBridge { static Future setStoreRegistryUrl(String registryUrl) async { _log.d('setStoreRegistryUrl: $registryUrl'); - await _channel.invokeMethod('setStoreRegistryUrl', {'registry_url': registryUrl}); + await _channel.invokeMethod('setStoreRegistryUrl', { + 'registry_url': registryUrl, + }); } static Future getStoreRegistryUrl() async {