mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
feat: add Qobuz and Tidal metadata API, URL parsers, and full store support
This commit is contained in:
parent
d89850e8a9
commit
ac9141f167
13 changed files with 2230 additions and 133 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
181
go_backend/tidal_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue