mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 00:39:24 +02:00
2512 lines
72 KiB
Go
2512 lines
72 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type TidalDownloader struct {
|
|
client *http.Client
|
|
apiURL string
|
|
}
|
|
|
|
var (
|
|
globalTidalDownloader *TidalDownloader
|
|
tidalDownloaderOnce sync.Once
|
|
tidalGetTrackSearchPageFunc = func(t *TidalDownloader, query string, limit int) (*tidalPublicTrackSearchResponse, error) {
|
|
return t.getTrackSearchPage(query, limit)
|
|
}
|
|
tidalGetPublicTrackFunc = func(t *TidalDownloader, resourceID string) (*TidalTrack, error) {
|
|
return t.getPublicTrack(resourceID)
|
|
}
|
|
)
|
|
|
|
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 {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
ISRC string `json:"isrc"`
|
|
AudioQuality string `json:"audioQuality"`
|
|
TrackNumber int `json:"trackNumber"`
|
|
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 {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Picture string `json:"picture"`
|
|
} `json:"artists"`
|
|
Artist struct {
|
|
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 {
|
|
Version string `json:"version"`
|
|
Data struct {
|
|
TrackID int64 `json:"trackId"`
|
|
AssetPresentation string `json:"assetPresentation"`
|
|
AudioMode string `json:"audioMode"`
|
|
AudioQuality string `json:"audioQuality"`
|
|
ManifestMimeType string `json:"manifestMimeType"`
|
|
ManifestHash string `json:"manifestHash"`
|
|
Manifest string `json:"manifest"`
|
|
BitDepth int `json:"bitDepth"`
|
|
SampleRate int `json:"sampleRate"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
type TidalBTSManifest struct {
|
|
MimeType string `json:"mimeType"`
|
|
Codecs string `json:"codecs"`
|
|
EncryptionType string `json:"encryptionType"`
|
|
URLs []string `json:"urls"`
|
|
}
|
|
|
|
type MPD struct {
|
|
XMLName xml.Name `xml:"MPD"`
|
|
Period struct {
|
|
AdaptationSet struct {
|
|
Representation struct {
|
|
SegmentTemplate struct {
|
|
Initialization string `xml:"initialization,attr"`
|
|
Media string `xml:"media,attr"`
|
|
Timeline struct {
|
|
Segments []struct {
|
|
Duration int `xml:"d,attr"`
|
|
Repeat int `xml:"r,attr"`
|
|
} `xml:"S"`
|
|
} `xml:"SegmentTimeline"`
|
|
} `xml:"SegmentTemplate"`
|
|
} `xml:"Representation"`
|
|
} `xml:"AdaptationSet"`
|
|
} `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"`
|
|
Title string `json:"title"`
|
|
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"`
|
|
}
|
|
|
|
type tidalPublicTrackSearchResponse struct {
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
TotalNumberOfItems int `json:"totalNumberOfItems"`
|
|
Items []TidalTrack `json:"items"`
|
|
}
|
|
|
|
func NewTidalDownloader() *TidalDownloader {
|
|
tidalDownloaderOnce.Do(func() {
|
|
globalTidalDownloader = &TidalDownloader{
|
|
client: NewHTTPClientWithTimeout(DefaultTimeout),
|
|
}
|
|
|
|
apis := globalTidalDownloader.GetAvailableAPIs()
|
|
if len(apis) > 0 {
|
|
globalTidalDownloader.apiURL = apis[0]
|
|
}
|
|
})
|
|
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 {
|
|
return tidalAlbumToArtistAlbumWithType(album, "")
|
|
}
|
|
|
|
func tidalAlbumToArtistAlbumWithType(album *tidalPublicAlbum, fallbackType string) ArtistAlbumMetadata {
|
|
if album == nil {
|
|
return ArtistAlbumMetadata{}
|
|
}
|
|
|
|
albumType := strings.ToLower(strings.TrimSpace(album.Type))
|
|
if albumType == "" {
|
|
albumType = strings.ToLower(strings.TrimSpace(fallbackType))
|
|
}
|
|
if albumType == "" {
|
|
albumType = "album"
|
|
}
|
|
|
|
return ArtistAlbumMetadata{
|
|
ID: tidalPrefixedNumericID(album.ID),
|
|
Name: strings.TrimSpace(album.Title),
|
|
ReleaseDate: strings.TrimSpace(album.ReleaseDate),
|
|
TotalTracks: album.NumberOfTracks,
|
|
Images: tidalImageURL(album.Cover, "1280x1280"),
|
|
AlbumType: albumType,
|
|
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 tidalArtistAlbumTypeFromModuleTitle(title string) string {
|
|
normalized := strings.ToLower(strings.TrimSpace(title))
|
|
switch normalized {
|
|
case "albums", "compilations", "appears on":
|
|
return "album"
|
|
case "ep & singles", "eps & singles", "singles", "ep", "eps":
|
|
return "single"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func tidalBuildMetadataURL(path string, extraQuery url.Values) string {
|
|
trimmedPath := strings.TrimLeft(strings.TrimSpace(path), "/")
|
|
if trimmedPath == "" {
|
|
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 (t *TidalDownloader) getTrackSearchPage(query string, limit int) (*tidalPublicTrackSearchResponse, error) {
|
|
cleanQuery := strings.TrimSpace(query)
|
|
if cleanQuery == "" {
|
|
return nil, fmt.Errorf("empty tidal search query")
|
|
}
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
requestURL := tidalBuildMetadataURL(
|
|
"search/tracks",
|
|
url.Values{
|
|
"query": {cleanQuery},
|
|
"limit": {strconv.Itoa(limit)},
|
|
"offset": {"0"},
|
|
},
|
|
)
|
|
var page tidalPublicTrackSearchResponse
|
|
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"`
|
|
Title string `json:"title"`
|
|
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",
|
|
"https://tidal.kinoplus.online",
|
|
"https://triton.squid.wtf",
|
|
"https://vogel.qqdl.site",
|
|
"https://maus.qqdl.site",
|
|
"https://hund.qqdl.site",
|
|
"https://katze.qqdl.site",
|
|
"https://wolf.qqdl.site",
|
|
"https://hifi-one.spotisaver.net",
|
|
"https://hifi-two.spotisaver.net",
|
|
}
|
|
}
|
|
|
|
func (t *TidalDownloader) GetAccessToken() (string, error) {
|
|
return "", fmt.Errorf("tidal official metadata API disabled: no client credentials mode")
|
|
}
|
|
|
|
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
|
spotifyURL := fmt.Sprintf("%s%s", spotifyTrackBaseURL, spotifyTrackID)
|
|
apiURL := fmt.Sprintf("%s%s", songLinkLookupBaseURL, url.QueryEscape(spotifyURL))
|
|
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := DoRequestWithUserAgent(t.client, req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get Tidal URL: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return "", fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var songLinkResp struct {
|
|
LinksByPlatform map[string]struct {
|
|
URL string `json:"url"`
|
|
} `json:"linksByPlatform"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
|
|
return "", fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]
|
|
if !ok || tidalLink.URL == "" {
|
|
return "", fmt.Errorf("tidal link not found in SongLink")
|
|
}
|
|
|
|
return tidalLink.URL, nil
|
|
}
|
|
|
|
func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
|
parts := strings.Split(tidalURL, "/track/")
|
|
if len(parts) < 2 {
|
|
return 0, fmt.Errorf("invalid tidal URL format")
|
|
}
|
|
|
|
trackIDStr := strings.Split(parts[1], "?")[0]
|
|
trackIDStr = strings.TrimSpace(trackIDStr)
|
|
|
|
var trackID int64
|
|
_, err := fmt.Sscanf(trackIDStr, "%d", &trackID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to parse track ID: %w", err)
|
|
}
|
|
|
|
return trackID, nil
|
|
}
|
|
|
|
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
|
return nil, fmt.Errorf("tidal track lookup API disabled: no client credentials mode")
|
|
}
|
|
|
|
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
|
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
|
if normalizedISRC == "" {
|
|
return nil, fmt.Errorf("empty tidal ISRC")
|
|
}
|
|
|
|
page, err := tidalGetTrackSearchPageFunc(t, normalizedISRC, 20)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i := range page.Items {
|
|
if strings.EqualFold(strings.TrimSpace(page.Items[i].ISRC), normalizedISRC) {
|
|
return &page.Items[i], nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("no exact tidal ISRC match found for %s", normalizedISRC)
|
|
}
|
|
|
|
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
|
queryParts := make([]string, 0, 3)
|
|
if trimmed := strings.TrimSpace(trackName); trimmed != "" {
|
|
queryParts = append(queryParts, trimmed)
|
|
}
|
|
if trimmed := strings.TrimSpace(artistName); trimmed != "" {
|
|
queryParts = append(queryParts, trimmed)
|
|
}
|
|
if len(queryParts) == 0 {
|
|
return nil, fmt.Errorf("tidal metadata search requires track or artist name")
|
|
}
|
|
|
|
queries := []string{strings.Join(queryParts, " ")}
|
|
if trimmedAlbum := strings.TrimSpace(albumName); trimmedAlbum != "" {
|
|
queries = append(queries, strings.Join(append(queryParts, trimmedAlbum), " "))
|
|
}
|
|
|
|
req := DownloadRequest{
|
|
TrackName: strings.TrimSpace(trackName),
|
|
ArtistName: strings.TrimSpace(artistName),
|
|
AlbumName: strings.TrimSpace(albumName),
|
|
ISRC: strings.ToUpper(strings.TrimSpace(spotifyISRC)),
|
|
DurationMS: expectedDuration * 1000,
|
|
}
|
|
|
|
seenQueries := make(map[string]struct{}, len(queries))
|
|
for _, query := range queries {
|
|
if _, seen := seenQueries[query]; seen {
|
|
continue
|
|
}
|
|
seenQueries[query] = struct{}{}
|
|
|
|
page, err := tidalGetTrackSearchPageFunc(t, query, 20)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var candidates []*TidalTrack
|
|
for i := range page.Items {
|
|
track := &page.Items[i]
|
|
if req.ISRC != "" && !strings.EqualFold(strings.TrimSpace(track.ISRC), req.ISRC) {
|
|
continue
|
|
}
|
|
resolved := resolvedTrackInfo{
|
|
Title: strings.TrimSpace(track.Title),
|
|
ArtistName: tidalTrackArtistsDisplay(track),
|
|
Duration: track.Duration,
|
|
}
|
|
if trackMatchesRequest(req, resolved, "Tidal search") {
|
|
candidates = append(candidates, track)
|
|
}
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
continue
|
|
}
|
|
|
|
if req.AlbumName != "" {
|
|
for _, candidate := range candidates {
|
|
if titlesMatch(req.AlbumName, candidate.Album.Title) {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return candidates[0], nil
|
|
}
|
|
|
|
if req.ISRC != "" {
|
|
return nil, fmt.Errorf("no tidal metadata match found for exact ISRC %s", req.ISRC)
|
|
}
|
|
return nil, fmt.Errorf("no tidal metadata match found")
|
|
}
|
|
|
|
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
|
|
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", "", 0)
|
|
}
|
|
|
|
func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
|
|
page, err := t.getTrackSearchPage(query, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
results := make([]ExtTrackMetadata, 0, len(page.Items))
|
|
for i := range page.Items {
|
|
results = append(results, normalizeBuiltInMetadataTrack(tidalTrackToTrackMetadata(&page.Items[i]), "tidal"))
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// SearchAll searches Tidal for tracks, artists, and albums matching the query.
|
|
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
|
|
func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
|
GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
|
|
|
cleanQuery := strings.TrimSpace(query)
|
|
if cleanQuery == "" {
|
|
return nil, fmt.Errorf("empty tidal search query")
|
|
}
|
|
|
|
albumLimit := 5
|
|
|
|
if filter != "" {
|
|
switch filter {
|
|
case "track":
|
|
trackLimit = 50
|
|
artistLimit = 0
|
|
albumLimit = 0
|
|
case "artist":
|
|
trackLimit = 0
|
|
artistLimit = 20
|
|
albumLimit = 0
|
|
case "album":
|
|
trackLimit = 0
|
|
artistLimit = 0
|
|
albumLimit = 20
|
|
}
|
|
}
|
|
|
|
result := &SearchAllResult{
|
|
Tracks: make([]TrackMetadata, 0, trackLimit),
|
|
Artists: make([]SearchArtistResult, 0, artistLimit),
|
|
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
|
Playlists: make([]SearchPlaylistResult, 0),
|
|
}
|
|
|
|
if trackLimit > 0 {
|
|
page, err := t.getTrackSearchPage(cleanQuery, trackLimit)
|
|
if err != nil {
|
|
GoLog("[Tidal] Track search failed: %v\n", err)
|
|
return nil, fmt.Errorf("tidal track search failed: %w", err)
|
|
}
|
|
GoLog("[Tidal] Got %d tracks from API\n", len(page.Items))
|
|
for i := range page.Items {
|
|
result.Tracks = append(result.Tracks, tidalTrackToTrackMetadata(&page.Items[i]))
|
|
}
|
|
}
|
|
|
|
if artistLimit > 0 {
|
|
requestURL := tidalBuildMetadataURL("search/artists", url.Values{
|
|
"query": {cleanQuery},
|
|
"limit": {strconv.Itoa(artistLimit)},
|
|
"offset": {"0"},
|
|
})
|
|
var artistResp struct {
|
|
Items []struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Picture string `json:"picture"`
|
|
Popularity int `json:"popularity"`
|
|
URL string `json:"url"`
|
|
} `json:"items"`
|
|
}
|
|
if err := t.getTidalMetadataJSON(requestURL, &artistResp); err == nil {
|
|
GoLog("[Tidal] Got %d artists from API\n", len(artistResp.Items))
|
|
for _, artist := range artistResp.Items {
|
|
result.Artists = append(result.Artists, SearchArtistResult{
|
|
ID: tidalPrefixedNumericID(artist.ID),
|
|
Name: strings.TrimSpace(artist.Name),
|
|
Images: tidalImageURL(artist.Picture, "750x750"),
|
|
Followers: 0,
|
|
Popularity: artist.Popularity,
|
|
})
|
|
}
|
|
} else {
|
|
GoLog("[Tidal] Artist search failed: %v\n", err)
|
|
}
|
|
}
|
|
|
|
if albumLimit > 0 {
|
|
requestURL := tidalBuildMetadataURL("search/albums", url.Values{
|
|
"query": {cleanQuery},
|
|
"limit": {strconv.Itoa(albumLimit)},
|
|
"offset": {"0"},
|
|
})
|
|
var albumResp struct {
|
|
Items []tidalPublicAlbum `json:"items"`
|
|
}
|
|
if err := t.getTidalMetadataJSON(requestURL, &albumResp); err == nil {
|
|
GoLog("[Tidal] Got %d albums from API\n", len(albumResp.Items))
|
|
for i := range albumResp.Items {
|
|
album := &albumResp.Items[i]
|
|
albumType := strings.ToLower(strings.TrimSpace(album.Type))
|
|
if albumType == "" {
|
|
albumType = "album"
|
|
}
|
|
result.Albums = append(result.Albums, SearchAlbumResult{
|
|
ID: tidalPrefixedNumericID(album.ID),
|
|
Name: strings.TrimSpace(album.Title),
|
|
Artists: tidalAlbumArtistsDisplay(album),
|
|
Images: tidalImageURL(album.Cover, "1280x1280"),
|
|
ReleaseDate: strings.TrimSpace(album.ReleaseDate),
|
|
TotalTracks: album.NumberOfTracks,
|
|
AlbumType: albumType,
|
|
})
|
|
}
|
|
} else {
|
|
GoLog("[Tidal] Album search failed: %v\n", err)
|
|
}
|
|
}
|
|
|
|
GoLog("[Tidal] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
|
|
return result, nil
|
|
}
|
|
|
|
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
|
|
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), "origin")
|
|
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)
|
|
seenAlbumIDs := make(map[string]struct{})
|
|
|
|
appendArtistAlbum := func(album tidalPublicAlbum, fallbackType string) {
|
|
mapped := tidalAlbumToArtistAlbumWithType(&album, fallbackType)
|
|
if mapped.ID == "" {
|
|
return
|
|
}
|
|
if _, exists := seenAlbumIDs[mapped.ID]; exists {
|
|
return
|
|
}
|
|
seenAlbumIDs[mapped.ID] = struct{}{}
|
|
albums = append(albums, mapped)
|
|
}
|
|
|
|
for rowIndex := range page.Rows {
|
|
for moduleIndex := range page.Rows[rowIndex].Modules {
|
|
module := &page.Rows[rowIndex].Modules[moduleIndex]
|
|
if module.Type != "ALBUM_LIST" {
|
|
continue
|
|
}
|
|
|
|
fallbackType := tidalArtistAlbumTypeFromModuleTitle(module.Title)
|
|
for _, album := range module.PagedList.Items {
|
|
appendArtistAlbum(album, fallbackType)
|
|
}
|
|
|
|
pageSize := module.PagedList.Limit
|
|
if pageSize <= 0 {
|
|
pageSize = 50
|
|
}
|
|
offset := len(module.PagedList.Items)
|
|
for offset < module.PagedList.TotalNumberOfItems && strings.TrimSpace(module.PagedList.DataAPIPath) != "" {
|
|
albumsPage, pageErr := t.getArtistAlbumsPage(module.PagedList.DataAPIPath, offset, pageSize)
|
|
if pageErr != nil {
|
|
return nil, pageErr
|
|
}
|
|
|
|
for _, album := range albumsPage.Items {
|
|
appendArtistAlbum(album, fallbackType)
|
|
}
|
|
|
|
if len(albumsPage.Items) == 0 || offset+len(albumsPage.Items) >= albumsPage.TotalNumberOfItems {
|
|
break
|
|
}
|
|
offset += len(albumsPage.Items)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
SampleRate int
|
|
}
|
|
|
|
type tidalAPIResult struct {
|
|
apiURL string
|
|
info TidalDownloadInfo
|
|
err error
|
|
duration time.Duration
|
|
}
|
|
|
|
// Mobile networks are more unstable, so we use longer timeouts
|
|
const (
|
|
tidalAPITimeoutMobile = 25 * time.Second
|
|
tidalMaxRetries = 2
|
|
tidalRetryDelay = 500 * time.Millisecond
|
|
)
|
|
|
|
func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) {
|
|
var lastErr error
|
|
retryDelay := tidalRetryDelay
|
|
|
|
for attempt := 0; attempt <= tidalMaxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay)
|
|
time.Sleep(retryDelay)
|
|
retryDelay *= 2
|
|
}
|
|
|
|
client := NewHTTPClientWithTimeout(timeout)
|
|
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
|
|
|
req, err := http.NewRequest("GET", reqURL, nil)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
lastErr = err
|
|
errStr := strings.ToLower(err.Error())
|
|
if strings.Contains(errStr, "timeout") ||
|
|
strings.Contains(errStr, "reset") ||
|
|
strings.Contains(errStr, "connection refused") ||
|
|
strings.Contains(errStr, "eof") {
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
if resp.StatusCode >= 500 {
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
continue
|
|
}
|
|
|
|
// 429 rate limit - wait and retry
|
|
if resp.StatusCode == 429 {
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
lastErr = fmt.Errorf("rate limited")
|
|
retryDelay = 2 * time.Second
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
return TidalDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
// Try V2 response format (with manifest)
|
|
var v2Response TidalAPIResponseV2
|
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
|
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
|
return TidalDownloadInfo{}, fmt.Errorf("returned PREVIEW instead of FULL")
|
|
}
|
|
|
|
return TidalDownloadInfo{
|
|
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
|
BitDepth: v2Response.Data.BitDepth,
|
|
SampleRate: v2Response.Data.SampleRate,
|
|
}, nil
|
|
}
|
|
|
|
// Try V1 response format
|
|
var v1Responses []struct {
|
|
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
|
}
|
|
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
|
for _, item := range v1Responses {
|
|
if item.OriginalTrackURL != "" {
|
|
return TidalDownloadInfo{
|
|
URL: item.OriginalTrackURL,
|
|
BitDepth: 16,
|
|
SampleRate: 44100,
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return TidalDownloadInfo{}, fmt.Errorf("no download URL or manifest in response")
|
|
}
|
|
|
|
if lastErr != nil {
|
|
return TidalDownloadInfo{}, lastErr
|
|
}
|
|
return TidalDownloadInfo{}, fmt.Errorf("all retries failed")
|
|
}
|
|
|
|
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
|
if len(apis) == 0 {
|
|
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
|
}
|
|
|
|
GoLog("[Tidal] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
|
|
|
|
resultChan := make(chan tidalAPIResult, len(apis))
|
|
startTime := time.Now()
|
|
|
|
for _, apiURL := range apis {
|
|
go func(api string) {
|
|
reqStart := time.Now()
|
|
info, err := fetchTidalURLWithRetry(api, trackID, quality, tidalAPITimeoutMobile)
|
|
resultChan <- tidalAPIResult{
|
|
apiURL: api,
|
|
info: info,
|
|
err: err,
|
|
duration: time.Since(reqStart),
|
|
}
|
|
}(apiURL)
|
|
}
|
|
|
|
var errors []string
|
|
|
|
for i := 0; i < len(apis); i++ {
|
|
result := <-resultChan
|
|
if result.err == nil {
|
|
GoLog("[Tidal] [Parallel] Got response from %s (%d-bit/%dHz) in %v\n",
|
|
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
|
|
|
|
go func(remaining int) {
|
|
for j := 0; j < remaining; j++ {
|
|
<-resultChan
|
|
}
|
|
}(len(apis) - i - 1)
|
|
|
|
GoLog("[Tidal] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
|
|
return result.apiURL, result.info, nil
|
|
}
|
|
errMsg := result.err.Error()
|
|
if len(errMsg) > 50 {
|
|
errMsg = errMsg[:50] + "..."
|
|
}
|
|
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
|
}
|
|
|
|
GoLog("[Tidal] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
|
|
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
|
}
|
|
|
|
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
|
|
apis := t.GetAvailableAPIs()
|
|
if len(apis) == 0 {
|
|
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
|
|
}
|
|
|
|
_, info, err := getDownloadURLParallel(apis, trackID, quality)
|
|
if err != nil {
|
|
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
|
|
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
|
if err != nil {
|
|
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err)
|
|
}
|
|
|
|
manifestStr := string(manifestBytes)
|
|
|
|
manifestPreview := manifestStr
|
|
if len(manifestPreview) > 500 {
|
|
manifestPreview = manifestPreview[:500] + "..."
|
|
}
|
|
GoLog("[Tidal] Manifest content: %s\n", manifestPreview)
|
|
|
|
if strings.HasPrefix(manifestStr, "{") {
|
|
var btsManifest TidalBTSManifest
|
|
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
|
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
|
|
}
|
|
|
|
if len(btsManifest.URLs) == 0 {
|
|
return "", "", nil, fmt.Errorf("no URLs in BTS manifest")
|
|
}
|
|
|
|
return btsManifest.URLs[0], "", nil, nil
|
|
}
|
|
|
|
var mpd MPD
|
|
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
|
|
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
|
|
}
|
|
|
|
segTemplate := mpd.Period.AdaptationSet.Representation.SegmentTemplate
|
|
initURL = segTemplate.Initialization
|
|
mediaTemplate := segTemplate.Media
|
|
|
|
if initURL == "" || mediaTemplate == "" {
|
|
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
|
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
|
|
|
if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
|
initURL = match[1]
|
|
}
|
|
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
|
mediaTemplate = match[1]
|
|
}
|
|
}
|
|
|
|
if initURL == "" {
|
|
return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
|
|
}
|
|
|
|
initURL = strings.ReplaceAll(initURL, "&", "&")
|
|
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
|
|
|
segmentCount := 0
|
|
GoLog("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
|
|
for i, seg := range segTemplate.Timeline.Segments {
|
|
GoLog("[Tidal] Segment[%d]: d=%d, r=%d\n", i, seg.Duration, seg.Repeat)
|
|
segmentCount += seg.Repeat + 1
|
|
}
|
|
GoLog("[Tidal] Segment count from XML: %d\n", segmentCount)
|
|
|
|
if segmentCount == 0 {
|
|
fmt.Println("[Tidal] No segments from XML, trying regex...")
|
|
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
|
|
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
|
GoLog("[Tidal] Regex found %d segment entries\n", len(matches))
|
|
for i, match := range matches {
|
|
repeat := 0
|
|
if len(match) > 2 && match[2] != "" {
|
|
fmt.Sscanf(match[2], "%d", &repeat)
|
|
}
|
|
if i < 5 || i == len(matches)-1 {
|
|
GoLog("[Tidal] Regex segment[%d]: d=%s, r=%d\n", i, match[1], repeat)
|
|
}
|
|
segmentCount += repeat + 1
|
|
}
|
|
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
|
|
}
|
|
|
|
if segmentCount == 0 {
|
|
return "", "", nil, fmt.Errorf("no segments found in manifest")
|
|
}
|
|
|
|
for i := 1; i <= segmentCount; i++ {
|
|
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
|
mediaURLs = append(mediaURLs, mediaURL)
|
|
}
|
|
|
|
return "", initURL, mediaURLs, nil
|
|
}
|
|
|
|
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
|
ctx := context.Background()
|
|
|
|
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
|
if itemID != "" {
|
|
StartItemProgress(itemID)
|
|
defer CompleteItemProgress(itemID)
|
|
ctx = initDownloadCancel(itemID)
|
|
defer clearDownloadCancel(itemID)
|
|
}
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, outputFD, itemID)
|
|
}
|
|
|
|
if itemID != "" {
|
|
StartItemProgress(itemID)
|
|
defer CompleteItemProgress(itemID)
|
|
ctx = initDownloadCancel(itemID)
|
|
defer clearDownloadCancel(itemID)
|
|
}
|
|
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := DoRequestWithUserAgent(t.client, req)
|
|
if err != nil {
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
expectedSize := resp.ContentLength
|
|
if expectedSize > 0 && itemID != "" {
|
|
SetItemBytesTotal(itemID, expectedSize)
|
|
}
|
|
|
|
out, err := openOutputForWrite(outputPath, outputFD)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
|
|
|
var written int64
|
|
if itemID != "" {
|
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
|
written, err = io.Copy(progressWriter, resp.Body)
|
|
} else {
|
|
written, err = io.Copy(bufWriter, resp.Body)
|
|
}
|
|
|
|
flushErr := bufWriter.Flush()
|
|
closeErr := out.Close()
|
|
|
|
if err != nil {
|
|
cleanupOutputOnError(outputPath, outputFD)
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
return fmt.Errorf("download interrupted: %w", err)
|
|
}
|
|
if flushErr != nil {
|
|
cleanupOutputOnError(outputPath, outputFD)
|
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
|
}
|
|
if closeErr != nil {
|
|
cleanupOutputOnError(outputPath, outputFD)
|
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
|
}
|
|
|
|
if expectedSize > 0 && written != expectedSize {
|
|
cleanupOutputOnError(outputPath, outputFD)
|
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath string, outputFD int, itemID string) error {
|
|
fmt.Println("[Tidal] Parsing manifest...")
|
|
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
|
if err != nil {
|
|
GoLog("[Tidal] Manifest parse error: %v\n", err)
|
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
|
}
|
|
GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n",
|
|
directURL != "", initURL != "", len(mediaURLs))
|
|
|
|
client := NewHTTPClientWithTimeout(DownloadTimeout)
|
|
|
|
if directURL != "" {
|
|
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", directURL, nil)
|
|
if err != nil {
|
|
GoLog("[Tidal] BTS request creation failed: %v\n", err)
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
GoLog("[Tidal] BTS download failed: %v\n", err)
|
|
return fmt.Errorf("failed to download file: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
GoLog("[Tidal] BTS download HTTP error: %d\n", resp.StatusCode)
|
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
|
}
|
|
GoLog("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
|
|
|
|
expectedSize := resp.ContentLength
|
|
if expectedSize > 0 && itemID != "" {
|
|
SetItemBytesTotal(itemID, expectedSize)
|
|
}
|
|
|
|
out, err := openOutputForWrite(outputPath, outputFD)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create file: %w", err)
|
|
}
|
|
|
|
var written int64
|
|
if itemID != "" {
|
|
progressWriter := NewItemProgressWriter(out, itemID)
|
|
written, err = io.Copy(progressWriter, resp.Body)
|
|
} else {
|
|
written, err = io.Copy(out, resp.Body)
|
|
}
|
|
|
|
closeErr := out.Close()
|
|
|
|
if err != nil {
|
|
cleanupOutputOnError(outputPath, outputFD)
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
return fmt.Errorf("download interrupted: %w", err)
|
|
}
|
|
if closeErr != nil {
|
|
cleanupOutputOnError(outputPath, outputFD)
|
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
|
}
|
|
|
|
if expectedSize > 0 && written != expectedSize {
|
|
cleanupOutputOnError(outputPath, outputFD)
|
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// For DASH format, determine correct M4A path
|
|
// If outputPath already ends with .m4a, use it directly.
|
|
// If outputPath ends with .flac, convert .flac to .m4a.
|
|
// Otherwise (e.g., SAF /proc/self/fd/*), use outputPath as-is.
|
|
var m4aPath string
|
|
if strings.HasSuffix(outputPath, ".m4a") {
|
|
m4aPath = outputPath
|
|
} else if strings.HasSuffix(outputPath, ".flac") {
|
|
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
|
} else {
|
|
m4aPath = outputPath
|
|
}
|
|
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
|
|
|
out, err := openOutputForWrite(m4aPath, outputFD)
|
|
if err != nil {
|
|
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
|
|
return fmt.Errorf("failed to create M4A file: %w", err)
|
|
}
|
|
|
|
GoLog("[Tidal] Downloading init segment...\n")
|
|
if isDownloadCancelled(itemID) {
|
|
out.Close()
|
|
cleanupOutputOnError(m4aPath, outputFD)
|
|
return ErrDownloadCancelled
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
|
|
if err != nil {
|
|
out.Close()
|
|
cleanupOutputOnError(m4aPath, outputFD)
|
|
GoLog("[Tidal] Init segment request failed: %v\n", err)
|
|
return fmt.Errorf("failed to create init segment request: %w", err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
out.Close()
|
|
cleanupOutputOnError(m4aPath, outputFD)
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
GoLog("[Tidal] Init segment download failed: %v\n", err)
|
|
return fmt.Errorf("failed to download init segment: %w", err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
resp.Body.Close()
|
|
out.Close()
|
|
cleanupOutputOnError(m4aPath, outputFD)
|
|
GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
|
|
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
|
}
|
|
_, err = io.Copy(out, resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
out.Close()
|
|
cleanupOutputOnError(m4aPath, outputFD)
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
GoLog("[Tidal] Init segment write failed: %v\n", err)
|
|
return fmt.Errorf("failed to write init segment: %w", err)
|
|
}
|
|
|
|
totalSegments := len(mediaURLs)
|
|
for i, mediaURL := range mediaURLs {
|
|
if isDownloadCancelled(itemID) {
|
|
out.Close()
|
|
cleanupOutputOnError(m4aPath, outputFD)
|
|
return ErrDownloadCancelled
|
|
}
|
|
|
|
if i%10 == 0 || i == totalSegments-1 {
|
|
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
|
}
|
|
|
|
if itemID != "" {
|
|
progress := float64(i+1) / float64(totalSegments)
|
|
SetItemProgress(itemID, progress, 0, 0)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
|
|
if err != nil {
|
|
out.Close()
|
|
cleanupOutputOnError(m4aPath, outputFD)
|
|
GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err)
|
|
return fmt.Errorf("failed to create segment %d request: %w", i+1, err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
out.Close()
|
|
cleanupOutputOnError(m4aPath, outputFD)
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err)
|
|
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
resp.Body.Close()
|
|
out.Close()
|
|
cleanupOutputOnError(m4aPath, outputFD)
|
|
GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
|
|
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
|
}
|
|
_, err = io.Copy(out, resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
out.Close()
|
|
cleanupOutputOnError(m4aPath, outputFD)
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
|
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
|
}
|
|
}
|
|
|
|
if err := out.Close(); err != nil {
|
|
cleanupOutputOnError(m4aPath, outputFD)
|
|
GoLog("[Tidal] Failed to close M4A file: %v\n", err)
|
|
return fmt.Errorf("failed to close M4A file: %w", err)
|
|
}
|
|
|
|
GoLog("[Tidal] DASH download completed: %s\n", m4aPath)
|
|
return nil
|
|
}
|
|
|
|
type TidalDownloadResult struct {
|
|
FilePath string
|
|
BitDepth int
|
|
SampleRate int
|
|
Title string
|
|
Artist string
|
|
Album string
|
|
ReleaseDate string
|
|
TrackNumber int
|
|
DiscNumber int
|
|
ISRC string
|
|
LyricsLRC string // LRC content for embedding in converted files
|
|
}
|
|
|
|
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
|
normSpotify := normalizeLooseArtistName(spotifyArtist)
|
|
normTidal := normalizeLooseArtistName(tidalArtist)
|
|
|
|
if normSpotify == normTidal {
|
|
return true
|
|
}
|
|
|
|
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
|
return true
|
|
}
|
|
|
|
spotifyArtists := splitArtists(normSpotify)
|
|
tidalArtists := splitArtists(normTidal)
|
|
|
|
for _, exp := range spotifyArtists {
|
|
for _, fnd := range tidalArtists {
|
|
if exp == fnd {
|
|
return true
|
|
}
|
|
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
|
return true
|
|
}
|
|
if sameWordsUnordered(exp, fnd) {
|
|
GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
spotifyLatin := isLatinScript(spotifyArtist)
|
|
tidalLatin := isLatinScript(tidalArtist)
|
|
if spotifyLatin != tidalLatin {
|
|
GoLog("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func splitArtists(artists string) []string {
|
|
normalized := artists
|
|
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " ft ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " & ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " and ", "|")
|
|
normalized = strings.ReplaceAll(normalized, ", ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " x ", "|")
|
|
|
|
parts := strings.Split(normalized, "|")
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
trimmed := strings.TrimSpace(p)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func sameWordsUnordered(a, b string) bool {
|
|
wordsA := strings.Fields(a)
|
|
wordsB := strings.Fields(b)
|
|
|
|
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
|
return false
|
|
}
|
|
|
|
sortedA := make([]string, len(wordsA))
|
|
sortedB := make([]string, len(wordsB))
|
|
copy(sortedA, wordsA)
|
|
copy(sortedB, wordsB)
|
|
|
|
for i := 0; i < len(sortedA)-1; i++ {
|
|
for j := i + 1; j < len(sortedA); j++ {
|
|
if sortedA[i] > sortedA[j] {
|
|
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
|
|
}
|
|
if sortedB[i] > sortedB[j] {
|
|
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
for i := range sortedA {
|
|
if sortedA[i] != sortedB[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func titlesMatch(expectedTitle, foundTitle string) bool {
|
|
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
|
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
|
|
|
if normExpected == normFound {
|
|
return true
|
|
}
|
|
|
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
|
return true
|
|
}
|
|
|
|
cleanExpected := cleanTitle(normExpected)
|
|
cleanFound := cleanTitle(normFound)
|
|
|
|
if cleanExpected == cleanFound {
|
|
return true
|
|
}
|
|
|
|
if cleanExpected != "" && cleanFound != "" {
|
|
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
coreExpected := extractCoreTitle(normExpected)
|
|
coreFound := extractCoreTitle(normFound)
|
|
|
|
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
|
return true
|
|
}
|
|
|
|
looseExpected := normalizeLooseTitle(normExpected)
|
|
looseFound := normalizeLooseTitle(normFound)
|
|
if looseExpected != "" && looseFound != "" {
|
|
if looseExpected == looseFound {
|
|
return true
|
|
}
|
|
if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Emoji/symbol-only titles must be matched strictly to avoid false positives
|
|
// like mapping "🪐" to "Higher Power".
|
|
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
|
|
strings.TrimSpace(expectedTitle) != "" &&
|
|
strings.TrimSpace(foundTitle) != "" {
|
|
expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle)
|
|
foundSymbols := normalizeSymbolOnlyTitle(foundTitle)
|
|
if expectedSymbols != "" && foundSymbols != "" && expectedSymbols == foundSymbols {
|
|
GoLog("[Tidal] Symbol-heavy title matched strictly: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
|
return true
|
|
}
|
|
GoLog("[Tidal] Symbol-heavy title mismatch: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
|
return false
|
|
}
|
|
|
|
expectedLatin := isLatinScript(expectedTitle)
|
|
foundLatin := isLatinScript(foundTitle)
|
|
if expectedLatin != foundLatin {
|
|
GoLog("[Tidal] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func extractCoreTitle(title string) string {
|
|
parenIdx := strings.Index(title, "(")
|
|
bracketIdx := strings.Index(title, "[")
|
|
dashIdx := strings.Index(title, " - ")
|
|
|
|
cutIdx := len(title)
|
|
if parenIdx > 0 && parenIdx < cutIdx {
|
|
cutIdx = parenIdx
|
|
}
|
|
if bracketIdx > 0 && bracketIdx < cutIdx {
|
|
cutIdx = bracketIdx
|
|
}
|
|
if dashIdx > 0 && dashIdx < cutIdx {
|
|
cutIdx = dashIdx
|
|
}
|
|
|
|
return strings.TrimSpace(title[:cutIdx])
|
|
}
|
|
|
|
func cleanTitle(title string) string {
|
|
cleaned := title
|
|
|
|
versionPatterns := []string{
|
|
"remaster", "remastered", "deluxe", "bonus", "single",
|
|
"album version", "radio edit", "original mix", "extended",
|
|
"club mix", "remix", "live", "acoustic", "demo",
|
|
}
|
|
|
|
for {
|
|
startParen := strings.LastIndex(cleaned, "(")
|
|
endParen := strings.LastIndex(cleaned, ")")
|
|
if startParen >= 0 && endParen > startParen {
|
|
content := strings.ToLower(cleaned[startParen+1 : endParen])
|
|
isVersionIndicator := false
|
|
for _, pattern := range versionPatterns {
|
|
if strings.Contains(content, pattern) {
|
|
isVersionIndicator = true
|
|
break
|
|
}
|
|
}
|
|
if isVersionIndicator {
|
|
cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:]
|
|
continue
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
for {
|
|
startBracket := strings.LastIndex(cleaned, "[")
|
|
endBracket := strings.LastIndex(cleaned, "]")
|
|
if startBracket >= 0 && endBracket > startBracket {
|
|
content := strings.ToLower(cleaned[startBracket+1 : endBracket])
|
|
isVersionIndicator := false
|
|
for _, pattern := range versionPatterns {
|
|
if strings.Contains(content, pattern) {
|
|
isVersionIndicator = true
|
|
break
|
|
}
|
|
}
|
|
if isVersionIndicator {
|
|
cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:]
|
|
continue
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
dashPatterns := []string{
|
|
" - remaster", " - remastered", " - single version", " - radio edit",
|
|
" - live", " - acoustic", " - demo", " - remix",
|
|
}
|
|
for _, pattern := range dashPatterns {
|
|
if strings.HasSuffix(strings.ToLower(cleaned), pattern) {
|
|
cleaned = cleaned[:len(cleaned)-len(pattern)]
|
|
}
|
|
}
|
|
|
|
for strings.Contains(cleaned, " ") {
|
|
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
|
}
|
|
|
|
return strings.TrimSpace(cleaned)
|
|
}
|
|
|
|
func isLatinScript(s string) bool {
|
|
for _, r := range s {
|
|
if r < 128 {
|
|
continue
|
|
}
|
|
if (r >= 0x0100 && r <= 0x024F) ||
|
|
(r >= 0x1E00 && r <= 0x1EFF) ||
|
|
(r >= 0x00C0 && r <= 0x00FF) {
|
|
continue
|
|
}
|
|
if (r >= 0x4E00 && r <= 0x9FFF) ||
|
|
(r >= 0x3040 && r <= 0x309F) ||
|
|
(r >= 0x30A0 && r <= 0x30FF) ||
|
|
(r >= 0xAC00 && r <= 0xD7AF) ||
|
|
(r >= 0x0600 && r <= 0x06FF) ||
|
|
(r >= 0x0400 && r <= 0x04FF) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseTidalRequestTrackID(raw string) (int64, bool) {
|
|
trimmed := strings.TrimSpace(raw)
|
|
trimmed = strings.TrimPrefix(trimmed, "tidal:")
|
|
if trimmed == "" {
|
|
return 0, false
|
|
}
|
|
|
|
trackID, err := strconv.ParseInt(trimmed, 10, 64)
|
|
if err != nil || trackID <= 0 {
|
|
return 0, false
|
|
}
|
|
return trackID, true
|
|
}
|
|
|
|
func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloader, logPrefix string) (*TidalTrack, error) {
|
|
if downloader == nil {
|
|
downloader = NewTidalDownloader()
|
|
}
|
|
if strings.TrimSpace(logPrefix) == "" {
|
|
logPrefix = "Tidal"
|
|
}
|
|
|
|
expectedDurationSec := req.DurationMS / 1000
|
|
var trackID int64
|
|
var gotTidalID bool
|
|
|
|
if req.TidalID != "" {
|
|
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
|
|
if parsedTrackID, ok := parseTidalRequestTrackID(req.TidalID); ok {
|
|
trackID = parsedTrackID
|
|
gotTidalID = true
|
|
}
|
|
}
|
|
|
|
if !gotTidalID && req.ISRC != "" {
|
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
|
GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.TidalTrackID)
|
|
trackID = cached.TidalTrackID
|
|
gotTidalID = true
|
|
}
|
|
}
|
|
|
|
if !gotTidalID && req.ISRC != "" {
|
|
GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
|
|
directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
|
|
if directErr == nil && directTrack != nil && directTrack.ID > 0 {
|
|
trackID = directTrack.ID
|
|
gotTidalID = true
|
|
GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
|
|
} else if directErr != nil {
|
|
GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
|
|
}
|
|
}
|
|
|
|
if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
|
|
GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
|
|
searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
|
|
req.TrackName,
|
|
req.ArtistName,
|
|
req.AlbumName,
|
|
req.ISRC,
|
|
expectedDurationSec,
|
|
)
|
|
if searchErr == nil && searchTrack != nil && searchTrack.ID > 0 {
|
|
trackID = searchTrack.ID
|
|
gotTidalID = true
|
|
GoLog("[%s] Got Tidal ID %d from public metadata search\n", logPrefix, trackID)
|
|
} else if searchErr != nil {
|
|
GoLog("[%s] Tidal public metadata search failed: %v\n", logPrefix, searchErr)
|
|
}
|
|
}
|
|
|
|
if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") {
|
|
GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix)
|
|
|
|
resolveFromAvailability := func(availability *TrackAvailability) {
|
|
if availability == nil || gotTidalID {
|
|
return
|
|
}
|
|
if availability.TidalID != "" {
|
|
if parsedTrackID, ok := parseTidalRequestTrackID(availability.TidalID); ok {
|
|
trackID = parsedTrackID
|
|
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
|
|
gotTidalID = true
|
|
return
|
|
}
|
|
}
|
|
if availability.TidalURL != "" {
|
|
var idErr error
|
|
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
|
|
if idErr == nil && trackID > 0 {
|
|
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
|
|
gotTidalID = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prefer Deezer-based SongLink lookup when DeezerID is available.
|
|
if req.DeezerID != "" {
|
|
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, req.DeezerID)
|
|
songlink := NewSongLinkClient()
|
|
availability, slErr := songlink.CheckAvailabilityFromDeezer(req.DeezerID)
|
|
if slErr == nil {
|
|
resolveFromAvailability(availability)
|
|
} else {
|
|
GoLog("[%s] SongLink Deezer lookup failed: %v\n", logPrefix, slErr)
|
|
}
|
|
}
|
|
|
|
if !gotTidalID && req.SpotifyID != "" {
|
|
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
|
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
|
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID)
|
|
songlink := NewSongLinkClient()
|
|
availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID)
|
|
if slErr == nil {
|
|
resolveFromAvailability(availability)
|
|
} else {
|
|
GoLog("[%s] SongLink Deezer lookup failed: %v\n", logPrefix, slErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !gotTidalID && req.SpotifyID != "" && !strings.HasPrefix(req.SpotifyID, "deezer:") {
|
|
songlink := NewSongLinkClient()
|
|
availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
|
if slErr == nil {
|
|
resolveFromAvailability(availability)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !gotTidalID || trackID <= 0 {
|
|
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
|
|
}
|
|
|
|
// Verify the resolved track matches the request.
|
|
actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10))
|
|
if fetchErr != nil {
|
|
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
|
|
// Continue without verification — better than failing entirely.
|
|
} else {
|
|
providerArtist := actualTrack.Artist.Name
|
|
if providerArtist == "" && len(actualTrack.Artists) > 0 {
|
|
providerArtist = actualTrack.Artists[0].Name
|
|
}
|
|
resolved := resolvedTrackInfo{
|
|
Title: actualTrack.Title,
|
|
ArtistName: providerArtist,
|
|
Duration: actualTrack.Duration,
|
|
}
|
|
if !trackMatchesRequest(req, resolved, logPrefix) {
|
|
// Invalidate the cached ID so future requests don't reuse it.
|
|
if req.ISRC != "" {
|
|
GetTrackIDCache().SetTidal(req.ISRC, 0)
|
|
}
|
|
return nil, fmt.Errorf("tidal track %d does not match request: expected '%s - %s', got '%s - %s'",
|
|
trackID, req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
|
}
|
|
GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
|
|
}
|
|
|
|
track := &TidalTrack{
|
|
ID: trackID,
|
|
Title: strings.TrimSpace(req.TrackName),
|
|
ISRC: strings.TrimSpace(req.ISRC),
|
|
Duration: expectedDurationSec,
|
|
TrackNumber: req.TrackNumber,
|
|
VolumeNumber: req.DiscNumber,
|
|
}
|
|
track.Artist.Name = strings.TrimSpace(req.ArtistName)
|
|
track.Album.Title = strings.TrimSpace(req.AlbumName)
|
|
track.Album.ReleaseDate = strings.TrimSpace(req.ReleaseDate)
|
|
|
|
if req.ISRC != "" {
|
|
GetTrackIDCache().SetTidal(req.ISRC, trackID)
|
|
}
|
|
return track, nil
|
|
}
|
|
|
|
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|
downloader := NewTidalDownloader()
|
|
|
|
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
|
if !isSafOutput {
|
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
|
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
|
}
|
|
}
|
|
|
|
track, err := resolveTidalTrackForRequest(req, downloader, "Tidal")
|
|
if err != nil {
|
|
return TidalDownloadResult{}, err
|
|
}
|
|
|
|
quality := req.Quality
|
|
if quality == "" {
|
|
quality = "LOSSLESS"
|
|
}
|
|
|
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
|
"title": req.TrackName,
|
|
"artist": req.ArtistName,
|
|
"album": req.AlbumName,
|
|
"track": req.TrackNumber,
|
|
"year": extractYear(req.ReleaseDate),
|
|
"date": req.ReleaseDate,
|
|
"disc": req.DiscNumber,
|
|
})
|
|
|
|
outputExt := strings.TrimSpace(req.OutputExt)
|
|
if outputExt == "" {
|
|
if quality == "HIGH" {
|
|
outputExt = ".m4a"
|
|
} else {
|
|
outputExt = ".flac"
|
|
}
|
|
} else if !strings.HasPrefix(outputExt, ".") {
|
|
outputExt = "." + outputExt
|
|
}
|
|
|
|
var outputPath string
|
|
var m4aPath string
|
|
if isSafOutput {
|
|
outputPath = strings.TrimSpace(req.OutputPath)
|
|
if outputPath == "" && isFDOutput(req.OutputFD) {
|
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
|
}
|
|
m4aPath = outputPath
|
|
} else {
|
|
if outputExt == ".m4a" || quality == "HIGH" {
|
|
filename = sanitizeFilename(filename) + ".m4a"
|
|
outputPath = filepath.Join(req.OutputDir, filename)
|
|
m4aPath = outputPath
|
|
} else {
|
|
filename = sanitizeFilename(filename) + ".flac"
|
|
outputPath = filepath.Join(req.OutputDir, filename)
|
|
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
|
}
|
|
|
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
|
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
|
}
|
|
if quality != "HIGH" {
|
|
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
|
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if !isSafOutput {
|
|
tmpPath := outputPath + ".m4a.tmp"
|
|
if _, err := os.Stat(tmpPath); err == nil {
|
|
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
|
os.Remove(tmpPath)
|
|
}
|
|
}
|
|
|
|
GoLog("[Tidal] Using quality: %s\n", quality)
|
|
|
|
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
|
if err != nil {
|
|
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
|
}
|
|
|
|
GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
|
|
|
var parallelResult *ParallelDownloadResult
|
|
parallelDone := make(chan struct{})
|
|
go func() {
|
|
defer close(parallelDone)
|
|
coverURL := req.CoverURL
|
|
embedLyrics := req.EmbedLyrics
|
|
if !req.EmbedMetadata {
|
|
coverURL = ""
|
|
embedLyrics = false
|
|
}
|
|
parallelResult = FetchCoverAndLyricsParallel(
|
|
coverURL,
|
|
req.EmbedMaxQualityCover,
|
|
req.SpotifyID,
|
|
req.TrackName,
|
|
req.ArtistName,
|
|
embedLyrics,
|
|
int64(req.DurationMS),
|
|
)
|
|
}()
|
|
|
|
GoLog("[Tidal] Starting download to: %s\n", outputPath)
|
|
GoLog("[Tidal] Download URL type: %s\n", func() string {
|
|
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
|
|
return "MANIFEST (DASH/BTS)"
|
|
}
|
|
return "Direct URL"
|
|
}())
|
|
|
|
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
|
if errors.Is(err, ErrDownloadCancelled) {
|
|
return TidalDownloadResult{}, ErrDownloadCancelled
|
|
}
|
|
GoLog("[Tidal] Download failed with error: %v\n", err)
|
|
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
|
}
|
|
fmt.Println("[Tidal] Download completed successfully")
|
|
|
|
<-parallelDone
|
|
|
|
if req.ItemID != "" {
|
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
|
SetItemFinalizing(req.ItemID)
|
|
}
|
|
|
|
actualOutputPath := outputPath
|
|
if !isSafOutput {
|
|
if _, err := os.Stat(m4aPath); err == nil {
|
|
actualOutputPath = m4aPath
|
|
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
|
} else if _, err := os.Stat(outputPath); err != nil {
|
|
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
|
}
|
|
}
|
|
|
|
releaseDate := req.ReleaseDate
|
|
if releaseDate == "" && track.Album.ReleaseDate != "" {
|
|
releaseDate = track.Album.ReleaseDate
|
|
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
|
|
}
|
|
|
|
actualTrackNumber := req.TrackNumber
|
|
actualDiscNumber := req.DiscNumber
|
|
if actualTrackNumber == 0 {
|
|
actualTrackNumber = track.TrackNumber
|
|
}
|
|
if actualDiscNumber == 0 {
|
|
actualDiscNumber = track.VolumeNumber
|
|
}
|
|
|
|
metadata := Metadata{
|
|
Title: req.TrackName,
|
|
Artist: req.ArtistName,
|
|
Album: req.AlbumName,
|
|
AlbumArtist: req.AlbumArtist,
|
|
Date: releaseDate,
|
|
TrackNumber: actualTrackNumber,
|
|
TotalTracks: req.TotalTracks,
|
|
DiscNumber: actualDiscNumber,
|
|
ISRC: track.ISRC,
|
|
Genre: req.Genre,
|
|
Label: req.Label,
|
|
Copyright: req.Copyright,
|
|
}
|
|
|
|
var coverData []byte
|
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
|
coverData = parallelResult.CoverData
|
|
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
|
}
|
|
|
|
actualExt := outputExt
|
|
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
|
|
actualExt = ".m4a"
|
|
}
|
|
if actualExt == "" && !isSafOutput {
|
|
actualExt = strings.ToLower(filepath.Ext(actualOutputPath))
|
|
}
|
|
|
|
if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) {
|
|
if req.EmbedMetadata {
|
|
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
|
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
|
}
|
|
} else {
|
|
GoLog("[Tidal] Metadata embedding disabled by settings, skipping FLAC metadata/lyrics embedding\n")
|
|
}
|
|
|
|
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
lyricsMode := req.LyricsMode
|
|
if lyricsMode == "" {
|
|
lyricsMode = "embed"
|
|
}
|
|
|
|
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
|
GoLog("[Tidal] Saving external LRC file...\n")
|
|
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
|
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
|
} else {
|
|
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
|
|
}
|
|
}
|
|
|
|
if lyricsMode == "embed" || lyricsMode == "both" {
|
|
GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
|
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
|
GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
} else {
|
|
fmt.Println("[Tidal] Lyrics embedded successfully")
|
|
}
|
|
}
|
|
} else if req.EmbedMetadata && req.EmbedLyrics {
|
|
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
|
}
|
|
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
|
|
if quality == "HIGH" {
|
|
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
|
|
|
|
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
lyricsMode := req.LyricsMode
|
|
if lyricsMode == "" {
|
|
lyricsMode = "embed"
|
|
}
|
|
|
|
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
|
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
|
|
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
|
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
|
} else {
|
|
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
|
}
|
|
}
|
|
|
|
if !isSafOutput {
|
|
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
|
}
|
|
|
|
bitDepth := downloadInfo.BitDepth
|
|
sampleRate := downloadInfo.SampleRate
|
|
if quality == "HIGH" {
|
|
bitDepth = 0
|
|
sampleRate = 44100
|
|
}
|
|
lyricsLRC := ""
|
|
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
lyricsLRC = parallelResult.LyricsLRC
|
|
}
|
|
|
|
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
|
|
req,
|
|
track.Album.Title,
|
|
track.Album.ReleaseDate,
|
|
actualTrackNumber,
|
|
actualDiscNumber,
|
|
)
|
|
|
|
return TidalDownloadResult{
|
|
FilePath: actualOutputPath,
|
|
BitDepth: bitDepth,
|
|
SampleRate: sampleRate,
|
|
Title: track.Title,
|
|
Artist: track.Artist.Name,
|
|
Album: resultAlbum,
|
|
ReleaseDate: resultReleaseDate,
|
|
TrackNumber: resultTrackNumber,
|
|
DiscNumber: resultDiscNumber,
|
|
ISRC: track.ISRC,
|
|
LyricsLRC: lyricsLRC,
|
|
}, nil
|
|
}
|
|
|
|
func parseTidalURL(input string) (string, string, error) {
|
|
trimmed := strings.TrimSpace(input)
|
|
if trimmed == "" {
|
|
return "", "", fmt.Errorf("empty URL")
|
|
}
|
|
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
if parsed.Host != "tidal.com" && parsed.Host != "listen.tidal.com" && parsed.Host != "www.tidal.com" {
|
|
return "", "", fmt.Errorf("not a Tidal URL")
|
|
}
|
|
|
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
|
|
|
// Handle /browse/track/123 format
|
|
if len(parts) > 0 && parts[0] == "browse" {
|
|
parts = parts[1:]
|
|
}
|
|
|
|
if len(parts) < 2 {
|
|
return "", "", fmt.Errorf("invalid Tidal URL format")
|
|
}
|
|
|
|
resourceType := parts[0]
|
|
resourceID := parts[1]
|
|
|
|
switch resourceType {
|
|
case "track", "album", "artist", "playlist":
|
|
return resourceType, resourceID, nil
|
|
default:
|
|
return "", "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
|
|
}
|
|
}
|