feat: add Qobuz and Tidal metadata API, URL parsers, and full store support

This commit is contained in:
zarzet 2026-03-14 15:09:48 +07:00
parent d89850e8a9
commit ac9141f167
13 changed files with 2230 additions and 133 deletions

View file

@ -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<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getQobuzMetadata(resourceType, resourceId)
}
result.success(response)
}
"getTidalMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getTidalMetadata(resourceType, resourceId)
}
result.success(response)
}
"parseDeezerUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
@ -2631,6 +2643,13 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"parseQobuzUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseQobuzURLExport(url)
}
result.success(response)
}
"parseTidalUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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(`
<div class="product__item">
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
</div>
<div class="product__item">
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
</div>
<div class="product__item">
<button data-itemtype="album" data-itemId="0886446451985"></button>
</div>
`)
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(`
<button data-itemtype="album" data-itemId="0886446451985"></button>
<button data-itemtype="album" data-itemId="0886446451985"></button>
<button data-itemtype="album" data-itemId="pvv406bth40ya"></button>
`)
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)
}
}

View file

@ -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

181
go_backend/tidal_test.go Normal file
View file

@ -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)
}
}

View file

@ -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

View file

@ -3605,10 +3605,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? 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<DownloadQueueState> {
genre: genre ?? '',
label: label ?? '',
copyright: copyright ?? '',
qobuzId: payloadQobuzId,
tidalId: payloadTidalId,
deezerId: deezerTrackId ?? '',
lyricsMode: settings.lyricsMode,
storageMode: storageMode,

View file

@ -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<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks
final List<SearchArtist>? searchArtists; // For search results
@ -384,6 +384,72 @@ class TrackNotifier extends Notifier<TrackState> {
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<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.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<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
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<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.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<TrackState> {
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<String, dynamic>;
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<String, dynamic>;
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<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.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<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
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<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.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;
}

View file

@ -84,7 +84,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
// 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<AlbumScreen> {
'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<AlbumScreen> {
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<AlbumScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToLoved(addedCount),
),
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
),
);
}

View file

@ -153,7 +153,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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<ArtistScreen> {
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.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<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
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<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
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<ArtistScreen> {
.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, 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<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, 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<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} else {
final url = 'https://open.spotify.com/album/${album.id}';
final result = await PlatformBridge.handleURLWithExtension(url);

View file

@ -65,16 +65,20 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
});
try {
// Extract numeric ID from "deezer:123" format
String playlistId = widget.playlistId!;
late final Map<String, dynamic> 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<PlaylistScreen> {
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<PlaylistScreen> {
} 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<PlaylistScreen> {
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<PlaylistScreen> {
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<PlaylistScreen> {
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<PlaylistScreen> {
} 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)),

View file

@ -535,11 +535,48 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> 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<String, dynamic>;
}
static Future<Map<String, dynamic>> parseQobuzUrl(String url) async {
final result = await _channel.invokeMethod('parseQobuzUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> parseTidalUrl(String url) async {
final result = await _channel.invokeMethod('parseTidalUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> 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<String, dynamic>;
}
static Future<Map<String, dynamic>> convertTidalToSpotifyDeezer(
String tidalUrl,
) async {
@ -1193,7 +1230,9 @@ class PlatformBridge {
static Future<void> setStoreRegistryUrl(String registryUrl) async {
_log.d('setStoreRegistryUrl: $registryUrl');
await _channel.invokeMethod('setStoreRegistryUrl', {'registry_url': registryUrl});
await _channel.invokeMethod('setStoreRegistryUrl', {
'registry_url': registryUrl,
});
}
static Future<String> getStoreRegistryUrl() async {