v7.0
This commit is contained in:
+19
-1
@@ -382,7 +382,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
|
||||
// Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, false)
|
||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
|
||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -403,6 +403,14 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
safeArtist := sanitizeFilename(spotifyArtistName)
|
||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||
safeAlbum := sanitizeFilename(spotifyAlbumName)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
// Extract year from release date
|
||||
year := ""
|
||||
if len(spotifyReleaseDate) >= 4 {
|
||||
year = spotifyReleaseDate[:4]
|
||||
}
|
||||
|
||||
// Build filename based on format settings
|
||||
var newFilename string
|
||||
@@ -412,6 +420,16 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
newFilename = filenameFormat
|
||||
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
|
||||
|
||||
// Handle disc number
|
||||
if spotifyDiscNumber > 0 {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
||||
} else {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
|
||||
+24
-2
@@ -22,10 +22,14 @@ type CoverDownloadRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
TrackNumber bool `json:"track_number"`
|
||||
Position int `json:"position"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
}
|
||||
|
||||
// CoverDownloadResponse represents the response from cover download
|
||||
@@ -50,9 +54,17 @@ func NewCoverClient() *CoverClient {
|
||||
}
|
||||
|
||||
// buildCoverFilename builds the cover filename based on settings (same as track filename)
|
||||
func buildCoverFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
|
||||
func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
safeAlbum := sanitizeFilename(albumName)
|
||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
var filename string
|
||||
|
||||
@@ -61,6 +73,16 @@ func buildCoverFilename(trackName, artistName, filenameFormat string, includeTra
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
@@ -176,7 +198,7 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
|
||||
if filenameFormat == "" {
|
||||
filenameFormat = "title-artist" // default
|
||||
}
|
||||
filename := buildCoverFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position)
|
||||
filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
|
||||
filePath := filepath.Join(outputDir, filename)
|
||||
|
||||
// Check if file already exists
|
||||
|
||||
@@ -341,12 +341,18 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
|
||||
|
||||
result := format
|
||||
|
||||
// Extract year (first 4 characters only)
|
||||
year := metadata.Year
|
||||
if len(year) >= 4 {
|
||||
year = year[:4]
|
||||
}
|
||||
|
||||
// Replace placeholders
|
||||
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
|
||||
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
|
||||
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
|
||||
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
|
||||
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(metadata.Year))
|
||||
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
|
||||
|
||||
// Track number with padding
|
||||
if metadata.TrackNumber > 0 {
|
||||
|
||||
+19
-1
@@ -10,10 +10,18 @@ import (
|
||||
)
|
||||
|
||||
// BuildExpectedFilename builds the expected filename based on track metadata and settings
|
||||
func BuildExpectedFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
|
||||
// Sanitize track name and artist name
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
safeAlbum := sanitizeFilename(albumName)
|
||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
var filename string
|
||||
|
||||
@@ -22,6 +30,16 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
|
||||
+24
-2
@@ -46,11 +46,15 @@ type LyricsDownloadRequest struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
TrackNumber bool `json:"track_number"`
|
||||
Position int `json:"position"`
|
||||
UseAlbumTrackNumber bool `json:"use_album_track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
}
|
||||
|
||||
// LyricsDownloadResponse represents the response from lyrics download
|
||||
@@ -308,9 +312,17 @@ func msToLRCTimestamp(msStr string) string {
|
||||
}
|
||||
|
||||
// buildLyricsFilename builds the lyrics filename based on settings (same as track filename)
|
||||
func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
|
||||
func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
safeAlbum := sanitizeFilename(albumName)
|
||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
var filename string
|
||||
|
||||
@@ -319,6 +331,16 @@ func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTr
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
@@ -378,7 +400,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
if filenameFormat == "" {
|
||||
filenameFormat = "title-artist" // default
|
||||
}
|
||||
filename := buildLyricsFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position)
|
||||
filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
|
||||
filePath := filepath.Join(outputDir, filename)
|
||||
|
||||
// Check if file already exists
|
||||
|
||||
+20
-2
@@ -263,7 +263,7 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
var filename string
|
||||
|
||||
// Determine track number to use
|
||||
@@ -272,11 +272,27 @@ func buildQobuzFilename(title, artist string, trackNumber int, format string, in
|
||||
numberToUse = trackNumber
|
||||
}
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(format, "{") {
|
||||
filename = format
|
||||
filename = strings.ReplaceAll(filename, "{title}", title)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", artist)
|
||||
filename = strings.ReplaceAll(filename, "{album}", album)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
|
||||
if numberToUse > 0 {
|
||||
@@ -355,6 +371,8 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
||||
|
||||
safeArtist := sanitizeFilename(artists)
|
||||
safeTitle := sanitizeFilename(trackTitle)
|
||||
safeAlbum := sanitizeFilename(albumTitle)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
// Check if file with same ISRC already exists (use Spotify ISRC)
|
||||
if existingFile, exists := CheckISRCExists(outputDir, isrc); exists {
|
||||
@@ -363,7 +381,7 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
||||
}
|
||||
|
||||
// Build filename based on format settings (use Spotify track number)
|
||||
filename := buildQobuzFilename(safeTitle, safeArtist, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
|
||||
|
||||
+359
-210
@@ -2,11 +2,7 @@ package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,8 +10,6 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -23,13 +17,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
spotifyTokenURL = "https://open.spotify.com/api/token"
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
secretBytesRemotePath = "https://cdn.jsdelivr.net/gh/afkarxyz/secretBytes@refs/heads/main/secrets/secretBytes.json"
|
||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -38,18 +31,37 @@ var (
|
||||
|
||||
// SpotifyMetadataClient mirrors the behaviour of Doc/getMetadata.py and interacts with Spotify's web API.
|
||||
type SpotifyMetadataClient struct {
|
||||
httpClient *http.Client
|
||||
rng *rand.Rand
|
||||
rngMu sync.Mutex
|
||||
userAgent string
|
||||
httpClient *http.Client
|
||||
clientID string
|
||||
clientSecret string
|
||||
cachedToken string
|
||||
tokenExpiresAt time.Time
|
||||
rng *rand.Rand
|
||||
rngMu sync.Mutex
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// NewSpotifyMetadataClient creates a ready-to-use client with sane defaults.
|
||||
// NewSpotifyMetadataClient creates a ready-to-use client with Official Spotify API credentials.
|
||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
// Decode client ID from base64
|
||||
clientID := ""
|
||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||
clientID = string(decoded)
|
||||
}
|
||||
|
||||
// Decode client secret from base64
|
||||
clientSecret := ""
|
||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
||||
clientSecret = string(decoded)
|
||||
}
|
||||
|
||||
c := &SpotifyMetadataClient{
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
rng: rand.New(src),
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
rng: rand.New(src),
|
||||
}
|
||||
c.userAgent = c.randomUserAgent()
|
||||
return c
|
||||
@@ -187,17 +199,10 @@ type spotifyURI struct {
|
||||
DiscographyGroup string
|
||||
}
|
||||
|
||||
type secretEntry struct {
|
||||
Version int `json:"version"`
|
||||
Secret []int `json:"secret"`
|
||||
}
|
||||
|
||||
type serverTimeResponse struct {
|
||||
ServerTime int64 `json:"serverTime"`
|
||||
}
|
||||
|
||||
type accessTokenResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn interface{} `json:"expires_in"` // Can be number or string
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
type image struct {
|
||||
@@ -352,7 +357,9 @@ func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed sp
|
||||
case "artist_discography":
|
||||
return c.fetchArtistDiscography(ctx, parsed, token, batch, delay)
|
||||
case "artist":
|
||||
return c.fetchArtist(ctx, parsed.ID, token)
|
||||
// Automatically fetch discography for artist URLs to get full data (albums + tracks)
|
||||
discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"}
|
||||
return c.fetchArtistDiscography(ctx, discographyParsed, token, batch, delay)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||
}
|
||||
@@ -859,211 +866,58 @@ func (c *SpotifyMetadataClient) randRange(min, max int) int {
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) {
|
||||
code, serverTime, version, err := c.generateTOTP(ctx)
|
||||
// Return cached token if still valid
|
||||
if c.cachedToken != "" && time.Now().Before(c.tokenExpiresAt) {
|
||||
return c.cachedToken, nil
|
||||
}
|
||||
|
||||
// Prepare request body for Client Credentials Flow
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "client_credentials")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, spotifyTokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
timestampMS := time.Now().UnixMilli()
|
||||
params := url.Values{}
|
||||
params.Set("reason", "init")
|
||||
params.Set("productType", "web-player")
|
||||
params.Set("totp", code)
|
||||
params.Set("totpServerTime", strconv.FormatInt(serverTime, 10))
|
||||
params.Set("totpVer", strconv.Itoa(version))
|
||||
params.Set("sTime", strconv.FormatInt(serverTime, 10))
|
||||
params.Set("cTime", strconv.FormatInt(timestampMS, 10))
|
||||
params.Set("buildVer", "web-player_2025-07-02_1720000000000_12345678")
|
||||
params.Set("buildDate", "2025-07-02")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, spotifyTokenURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.URL.RawQuery = params.Encode()
|
||||
req.Header = c.baseHeaders()
|
||||
// Set Basic Auth header
|
||||
req.SetBasicAuth(c.clientID, c.clientSecret)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("failed to get access token. Status code: %d", resp.StatusCode)
|
||||
return "", fmt.Errorf("failed to get access token. Status code: %d, Response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var token accessTokenResponse
|
||||
if err := json.Unmarshal(body, &token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if token.AccessToken == "" {
|
||||
return "", errors.New("failed to get access token: empty token received")
|
||||
}
|
||||
|
||||
// Cache the token
|
||||
c.cachedToken = token.AccessToken
|
||||
// Official API returns expires_in in seconds
|
||||
if expiresIn, ok := token.ExpiresIn.(float64); ok {
|
||||
c.tokenExpiresAt = time.Now().Add(time.Duration(expiresIn-60) * time.Second) // Refresh 60 seconds before expiry
|
||||
}
|
||||
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) generateTOTP(ctx context.Context) (string, int64, int, error) {
|
||||
secrets, _, err := c.fetchSecretBytes(ctx)
|
||||
if err != nil {
|
||||
return "", 0, 0, err
|
||||
}
|
||||
if len(secrets) == 0 {
|
||||
return "", 0, 0, errors.New("no secrets available")
|
||||
}
|
||||
|
||||
latest := secrets[0]
|
||||
for _, entry := range secrets[1:] {
|
||||
if entry.Version > latest.Version {
|
||||
latest = entry
|
||||
}
|
||||
}
|
||||
|
||||
builder := strings.Builder{}
|
||||
for idx, val := range latest.Secret {
|
||||
processed := val ^ ((idx % 33) + 9)
|
||||
builder.WriteString(strconv.Itoa(processed))
|
||||
}
|
||||
|
||||
utfBytes := []byte(builder.String())
|
||||
hexStr := hex.EncodeToString(utfBytes)
|
||||
secretBytes, err := hex.DecodeString(hexStr)
|
||||
if err != nil {
|
||||
return "", 0, 0, err
|
||||
}
|
||||
b32Secret := base32.StdEncoding.EncodeToString(secretBytes)
|
||||
|
||||
serverTime, err := c.fetchServerTime(ctx)
|
||||
if err != nil {
|
||||
return "", 0, 0, err
|
||||
}
|
||||
|
||||
code, err := computeTOTP(b32Secret, serverTime)
|
||||
if err != nil {
|
||||
return "", 0, 0, err
|
||||
}
|
||||
|
||||
return code, serverTime, latest.Version, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchSecretBytes(ctx context.Context) ([]secretEntry, bool, error) {
|
||||
// Add cache busting parameter with current timestamp
|
||||
urlWithCacheBust := fmt.Sprintf("%s?t=%d", secretBytesRemotePath, time.Now().Unix())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlWithCacheBust, nil)
|
||||
if err == nil {
|
||||
// Add headers to bypass cache
|
||||
req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
req.Header.Set("Expires", "0")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err == nil {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr == nil && resp.StatusCode == http.StatusOK {
|
||||
var secrets []secretEntry
|
||||
if jsonErr := json.Unmarshal(body, &secrets); jsonErr == nil {
|
||||
return secrets, false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("GitHub fetch failed and could not resolve home directory: %w", err)
|
||||
}
|
||||
localPath := filepath.Join(home, ".spotify-secret", "secretBytes.json")
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to fetch secrets from both GitHub and local: %w", err)
|
||||
}
|
||||
|
||||
var secrets []secretEntry
|
||||
if err := json.Unmarshal(data, &secrets); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to process local secrets: %w", err)
|
||||
}
|
||||
return secrets, true, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchServerTime(ctx context.Context) (int64, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://open.spotify.com/api/server-time", nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header = c.serverTimeHeaders()
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("failed to get server time. Status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload serverTimeResponse
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if payload.ServerTime == 0 {
|
||||
return 0, errors.New("failed to fetch server time from Spotify")
|
||||
}
|
||||
return payload.ServerTime, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) serverTimeHeaders() http.Header {
|
||||
h := http.Header{}
|
||||
h.Set("Host", "open.spotify.com")
|
||||
h.Set("User-Agent", c.randomUserAgent())
|
||||
h.Set("Accept", "*/*")
|
||||
return h
|
||||
}
|
||||
|
||||
func computeTOTP(b32Secret string, timestamp int64) (string, error) {
|
||||
normalized := strings.ToUpper(strings.ReplaceAll(b32Secret, " ", ""))
|
||||
key, err := base32.StdEncoding.DecodeString(normalized)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Normalise milliseconds if necessary.
|
||||
if timestamp > 1_000_000_000_000 {
|
||||
timestamp /= 1000
|
||||
}
|
||||
|
||||
counter := uint64(timestamp / 30)
|
||||
var buf [8]byte
|
||||
binary.BigEndian.PutUint64(buf[:], counter)
|
||||
|
||||
mac := hmac.New(sha1.New, key)
|
||||
if _, err := mac.Write(buf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := mac.Sum(nil)
|
||||
if len(sum) < 20 {
|
||||
return "", errors.New("unexpected hmac length for TOTP")
|
||||
}
|
||||
|
||||
offset := sum[len(sum)-1] & 0x0f
|
||||
binaryCode := (int(sum[offset])&0x7f)<<24 |
|
||||
(int(sum[offset+1])&0xff)<<16 |
|
||||
(int(sum[offset+2])&0xff)<<8 |
|
||||
(int(sum[offset+3]) & 0xff)
|
||||
otp := binaryCode % 1_000_000
|
||||
return fmt.Sprintf("%06d", otp), nil
|
||||
}
|
||||
|
||||
func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
@@ -1239,3 +1093,298 @@ func maxInt(a, b int) int {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
// SearchResult represents a single search result item
|
||||
type SearchResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // track, album, artist, playlist
|
||||
Artists string `json:"artists,omitempty"`
|
||||
AlbumName string `json:"album_name,omitempty"`
|
||||
Images string `json:"images"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
Duration int `json:"duration_ms,omitempty"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
Owner string `json:"owner,omitempty"` // for playlists
|
||||
}
|
||||
|
||||
// SearchResponse contains search results grouped by type
|
||||
type SearchResponse struct {
|
||||
Tracks []SearchResult `json:"tracks"`
|
||||
Albums []SearchResult `json:"albums"`
|
||||
Artists []SearchResult `json:"artists"`
|
||||
Playlists []SearchResult `json:"playlists"`
|
||||
}
|
||||
|
||||
// Spotify API search response structures
|
||||
type searchTracksResponse struct {
|
||||
Tracks struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Artists []artist `json:"artists"`
|
||||
Album struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
} `json:"album"`
|
||||
} `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
type searchAlbumsResponse struct {
|
||||
Albums struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AlbumType string `json:"album_type"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Images []image `json:"images"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Artists []artist `json:"artists"`
|
||||
} `json:"items"`
|
||||
} `json:"albums"`
|
||||
}
|
||||
|
||||
type searchArtistsResponse struct {
|
||||
Artists struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Followers struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"followers"`
|
||||
} `json:"items"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
|
||||
type searchPlaylistsResponse struct {
|
||||
Playlists struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Owner struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
} `json:"owner"`
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
} `json:"items"`
|
||||
} `json:"playlists"`
|
||||
}
|
||||
|
||||
// Search performs a search on Spotify and returns results for tracks, albums, artists, and playlists
|
||||
func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit int) (*SearchResponse, error) {
|
||||
if query == "" {
|
||||
return nil, errors.New("search query cannot be empty")
|
||||
}
|
||||
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
// URL encode the query
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=track,album,artist,playlist&limit=%d", encodedQuery, limit)
|
||||
|
||||
response := &SearchResponse{
|
||||
Tracks: make([]SearchResult, 0),
|
||||
Albums: make([]SearchResult, 0),
|
||||
Artists: make([]SearchResult, 0),
|
||||
Playlists: make([]SearchResult, 0),
|
||||
}
|
||||
|
||||
// Fetch tracks
|
||||
var tracksResp searchTracksResponse
|
||||
if err := c.getJSON(ctx, searchURL, token, &tracksResp); err != nil {
|
||||
return nil, fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range tracksResp.Tracks.Items {
|
||||
response.Tracks = append(response.Tracks, SearchResult{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Type: "track",
|
||||
Artists: joinArtists(item.Artists),
|
||||
AlbumName: item.Album.Name,
|
||||
Images: firstImageURL(item.Album.Images),
|
||||
ReleaseDate: item.Album.ReleaseDate,
|
||||
ExternalURL: item.ExternalURL.Spotify,
|
||||
Duration: item.DurationMS,
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch albums
|
||||
var albumsResp searchAlbumsResponse
|
||||
if err := c.getJSON(ctx, searchURL, token, &albumsResp); err == nil {
|
||||
for _, item := range albumsResp.Albums.Items {
|
||||
response.Albums = append(response.Albums, SearchResult{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Type: "album",
|
||||
Artists: joinArtists(item.Artists),
|
||||
Images: firstImageURL(item.Images),
|
||||
ReleaseDate: item.ReleaseDate,
|
||||
ExternalURL: item.ExternalURL.Spotify,
|
||||
TotalTracks: item.TotalTracks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch artists
|
||||
var artistsResp searchArtistsResponse
|
||||
if err := c.getJSON(ctx, searchURL, token, &artistsResp); err == nil {
|
||||
for _, item := range artistsResp.Artists.Items {
|
||||
response.Artists = append(response.Artists, SearchResult{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Type: "artist",
|
||||
Images: firstImageURL(item.Images),
|
||||
ExternalURL: item.ExternalURL.Spotify,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch playlists
|
||||
var playlistsResp searchPlaylistsResponse
|
||||
if err := c.getJSON(ctx, searchURL, token, &playlistsResp); err == nil {
|
||||
for _, item := range playlistsResp.Playlists.Items {
|
||||
response.Playlists = append(response.Playlists, SearchResult{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Type: "playlist",
|
||||
Images: firstImageURL(item.Images),
|
||||
ExternalURL: item.ExternalURL.Spotify,
|
||||
Owner: item.Owner.DisplayName,
|
||||
TotalTracks: item.Tracks.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// SearchSpotify is a convenience wrapper for the Search method
|
||||
func SearchSpotify(ctx context.Context, query string, limit int) (*SearchResponse, error) {
|
||||
client := NewSpotifyMetadataClient()
|
||||
return client.Search(ctx, query, limit)
|
||||
}
|
||||
|
||||
// SearchByType searches for a specific type (track, album, artist, playlist) with offset support
|
||||
func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) {
|
||||
if query == "" {
|
||||
return nil, errors.New("search query cannot be empty")
|
||||
}
|
||||
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
if offset < 0 || offset > 1000 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=%s&limit=%d&offset=%d", encodedQuery, searchType, limit, offset)
|
||||
|
||||
results := make([]SearchResult, 0)
|
||||
|
||||
switch searchType {
|
||||
case "track":
|
||||
var resp searchTracksResponse
|
||||
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
|
||||
return nil, fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
for _, item := range resp.Tracks.Items {
|
||||
results = append(results, SearchResult{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Type: "track",
|
||||
Artists: joinArtists(item.Artists),
|
||||
AlbumName: item.Album.Name,
|
||||
Images: firstImageURL(item.Album.Images),
|
||||
ReleaseDate: item.Album.ReleaseDate,
|
||||
ExternalURL: item.ExternalURL.Spotify,
|
||||
Duration: item.DurationMS,
|
||||
})
|
||||
}
|
||||
case "album":
|
||||
var resp searchAlbumsResponse
|
||||
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
|
||||
return nil, fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
for _, item := range resp.Albums.Items {
|
||||
results = append(results, SearchResult{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Type: "album",
|
||||
Artists: joinArtists(item.Artists),
|
||||
Images: firstImageURL(item.Images),
|
||||
ReleaseDate: item.ReleaseDate,
|
||||
ExternalURL: item.ExternalURL.Spotify,
|
||||
TotalTracks: item.TotalTracks,
|
||||
})
|
||||
}
|
||||
case "artist":
|
||||
var resp searchArtistsResponse
|
||||
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
|
||||
return nil, fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
for _, item := range resp.Artists.Items {
|
||||
results = append(results, SearchResult{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Type: "artist",
|
||||
Images: firstImageURL(item.Images),
|
||||
ExternalURL: item.ExternalURL.Spotify,
|
||||
})
|
||||
}
|
||||
case "playlist":
|
||||
var resp searchPlaylistsResponse
|
||||
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
|
||||
return nil, fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
for _, item := range resp.Playlists.Items {
|
||||
results = append(results, SearchResult{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Type: "playlist",
|
||||
Images: firstImageURL(item.Images),
|
||||
ExternalURL: item.ExternalURL.Spotify,
|
||||
Owner: item.Owner.DisplayName,
|
||||
TotalTracks: item.Tracks.Total,
|
||||
})
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid search type: %s", searchType)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SearchSpotifyByType is a convenience wrapper for SearchByType
|
||||
func SearchSpotifyByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) {
|
||||
client := NewSpotifyMetadataClient()
|
||||
return client.SearchByType(ctx, query, searchType, limit, offset)
|
||||
}
|
||||
|
||||
+45
-37
@@ -128,41 +128,25 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||
// Decode base64 API URL
|
||||
apiURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Fma2FyeHl6L1Nwb3RpRkxBQy9yZWZzL2hlYWRzL21haW4vdGlkYWwuanNvbg==")
|
||||
|
||||
// Add cache-busting parameter with current timestamp
|
||||
urlWithCacheBust := fmt.Sprintf("%s?t=%d", string(apiURL), time.Now().Unix())
|
||||
|
||||
// Create request with cache bypass headers
|
||||
req, err := http.NewRequest("GET", urlWithCacheBust, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Add headers to bypass cache
|
||||
req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
req.Header.Set("Expires", "0")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch API list: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("failed to fetch API list: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiList []string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiList); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode API list: %w", err)
|
||||
// Hardcoded API URLs (base64 encoded for obfuscation)
|
||||
encodedAPIs := []string{
|
||||
"dm9nZWwucXFkbC5zaXRl", // API 1
|
||||
"bWF1cy5xcWRsLnNpdGU=", // API 2
|
||||
"aHVuZC5xcWRsLnNpdGU=", // API 3
|
||||
"a2F0emUucXFkbC5zaXRl", // API 4
|
||||
"d29sZi5xcWRsLnNpdGU=", // API 5
|
||||
"dGlkYWwua2lub3BsdXMub25saW5l", // API 6
|
||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // API 7
|
||||
"dHJpdG9uLnNxdWlkLnd0Zg==", // API 8
|
||||
}
|
||||
|
||||
var apis []string
|
||||
for _, api := range apiList {
|
||||
apis = append(apis, "https://"+api)
|
||||
for _, encoded := range encodedAPIs {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
apis = append(apis, "https://"+string(decoded))
|
||||
}
|
||||
|
||||
return apis, nil
|
||||
@@ -834,6 +818,8 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
// Sanitize for filename only (not for metadata)
|
||||
artistNameForFile := sanitizeFilename(artistName)
|
||||
trackTitleForFile := sanitizeFilename(trackTitle)
|
||||
albumTitleForFile := sanitizeFilename(albumTitle)
|
||||
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
// Check if file with same ISRC already exists
|
||||
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
|
||||
@@ -842,7 +828,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
}
|
||||
|
||||
// Build filename based on format settings (use sanitized versions for filename)
|
||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
outputFilename := filepath.Join(outputDir, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -947,6 +933,8 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
// Sanitize for filename only (not for metadata)
|
||||
artistNameForFile := sanitizeFilename(artistName)
|
||||
trackTitleForFile := sanitizeFilename(trackTitle)
|
||||
albumTitleForFile := sanitizeFilename(albumTitle)
|
||||
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
// Check if file with same ISRC already exists
|
||||
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
|
||||
@@ -954,7 +942,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
return "EXISTS:" + existingFile, nil
|
||||
}
|
||||
|
||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
outputFilename := filepath.Join(outputDir, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -1081,6 +1069,8 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN
|
||||
// Sanitize for filename only (not for metadata)
|
||||
finalArtistNameForFile := sanitizeFilename(finalArtistName)
|
||||
finalTrackTitleForFile := sanitizeFilename(finalTrackTitle)
|
||||
finalAlbumTitleForFile := sanitizeFilename(finalAlbumTitle)
|
||||
finalAlbumArtistForFile := sanitizeFilename(albumArtist)
|
||||
|
||||
// Check if file with same ISRC already exists (use Spotify ISRC)
|
||||
if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists {
|
||||
@@ -1089,7 +1079,7 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN
|
||||
}
|
||||
|
||||
// Build filename
|
||||
filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, finalAlbumTitleForFile, finalAlbumArtistForFile, releaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
outputFilename := filepath.Join(outputDir, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -1405,6 +1395,8 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al
|
||||
// Sanitize for filename only (not for metadata)
|
||||
finalArtistNameForFile := sanitizeFilename(finalArtistName)
|
||||
finalTrackTitleForFile := sanitizeFilename(finalTrackTitle)
|
||||
finalAlbumTitleForFile := sanitizeFilename(finalAlbumTitle)
|
||||
finalAlbumArtistForFile := sanitizeFilename(albumArtist)
|
||||
|
||||
// Check if file already exists (use Spotify ISRC)
|
||||
if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists {
|
||||
@@ -1412,7 +1404,7 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al
|
||||
return "EXISTS:" + existingFile, nil
|
||||
}
|
||||
|
||||
filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, finalAlbumTitleForFile, finalAlbumArtistForFile, releaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
outputFilename := filepath.Join(outputDir, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -1511,7 +1503,7 @@ func (t *TidalDownloader) DownloadWithFallbackAndISRC(spotifyTrackID, spotifyISR
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC)
|
||||
}
|
||||
|
||||
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
var filename string
|
||||
|
||||
// Determine track number to use
|
||||
@@ -1520,11 +1512,27 @@ func buildTidalFilename(title, artist string, trackNumber int, format string, in
|
||||
numberToUse = trackNumber
|
||||
}
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(format, "{") {
|
||||
filename = format
|
||||
filename = strings.ReplaceAll(filename, "{title}", title)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", artist)
|
||||
filename = strings.ReplaceAll(filename, "{album}", album)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
|
||||
if numberToUse > 0 {
|
||||
|
||||
Reference in New Issue
Block a user