This commit is contained in:
afkarxyz
2026-04-26 07:33:40 +07:00
parent 30cbcf8ab1
commit 0093df6016
33 changed files with 2174 additions and 837 deletions
+80
View File
@@ -1,6 +1,9 @@
package backend
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
@@ -10,6 +13,7 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
@@ -23,6 +27,76 @@ type AmazonStreamResponse struct {
DecryptionKey string `json:"decryptionKey"`
}
var (
amazonMusicDebugKeyOnce sync.Once
amazonMusicDebugKey string
amazonMusicDebugKeyErr error
)
var amazonMusicDebugKeySeedParts = [][]byte{
[]byte("spotif"),
[]byte("lac:am"),
[]byte("azon:spotbye:api:v1"),
}
var amazonMusicDebugKeyAAD = []byte{
0x61, 0x6d, 0x61, 0x7a, 0x6f, 0x6e, 0x7c, 0x73, 0x70, 0x6f, 0x74, 0x62,
0x79, 0x65, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31,
}
var amazonMusicDebugKeyNonce = []byte{
0x52, 0x1f, 0xa4, 0x9c, 0x13, 0x77, 0x5b, 0xe2, 0x81, 0x44, 0x90, 0x6d,
}
var amazonMusicDebugKeyCiphertext = []byte{
0x5b, 0xf9, 0xc1, 0x2e, 0x58, 0xf8, 0x5b, 0xc0, 0x04, 0x68, 0x7e, 0xff,
0x3d, 0xd6, 0x8b, 0xe3, 0x86, 0x49, 0x6c, 0xfd, 0xc1, 0x49, 0x0b, 0xfb,
}
var amazonMusicDebugKeyTag = []byte{
0x6c, 0x21, 0x98, 0x51, 0xf2, 0x38, 0x4b, 0x4a, 0x23, 0xe1, 0xc6, 0xd7,
0x65, 0x7f, 0xfb, 0xa1,
}
func getAmazonMusicDebugKey() (string, error) {
amazonMusicDebugKeyOnce.Do(func() {
hasher := sha256.New()
for _, part := range amazonMusicDebugKeySeedParts {
hasher.Write(part)
}
block, err := aes.NewCipher(hasher.Sum(nil))
if err != nil {
amazonMusicDebugKeyErr = err
return
}
gcm, err := cipher.NewGCM(block)
if err != nil {
amazonMusicDebugKeyErr = err
return
}
sealed := make([]byte, 0, len(amazonMusicDebugKeyCiphertext)+len(amazonMusicDebugKeyTag))
sealed = append(sealed, amazonMusicDebugKeyCiphertext...)
sealed = append(sealed, amazonMusicDebugKeyTag...)
plaintext, err := gcm.Open(nil, amazonMusicDebugKeyNonce, sealed, amazonMusicDebugKeyAAD)
if err != nil {
amazonMusicDebugKeyErr = err
return
}
amazonMusicDebugKey = string(plaintext)
})
if amazonMusicDebugKeyErr != nil {
return "", amazonMusicDebugKeyErr
}
return amazonMusicDebugKey, nil
}
func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{
client: &http.Client{
@@ -62,6 +136,12 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
return "", err
}
debugKey, err := getAmazonMusicDebugKey()
if err != nil {
return "", fmt.Errorf("failed to decrypt Amazon debug key: %w", err)
}
req.Header.Set("X-Debug-Key", debugKey)
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
resp, err := a.client.Do(req)
if err != nil {
+34
View File
@@ -60,6 +60,40 @@ func GetRedownloadWithSuffixSetting() bool {
return enabled
}
func GetCustomTidalAPISetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
return ""
}
customAPI, _ := settings["customTidalApi"].(string)
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
if strings.HasPrefix(customAPI, "https://") {
return customAPI
}
return ""
}
func normalizeExistingFileCheckMode(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case "isrc", "upc":
return "isrc"
default:
return "filename"
}
}
func GetExistingFileCheckModeSetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
return "filename"
}
rawMode, _ := settings["existingFileCheckMode"].(string)
return normalizeExistingFileCheckMode(rawMode)
}
func GetLinkResolverSetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
+189 -52
View File
@@ -19,6 +19,11 @@ import (
"golang.org/x/text/unicode/norm"
)
type executableCandidate struct {
path string
source string
}
func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path)
if cleanedPath == "" {
@@ -83,6 +88,50 @@ func GetFFmpegDir() (string, error) {
return EnsureAppDir()
}
func copyExecutable(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return err
}
defer out.Close()
if _, err = io.Copy(out, in); err != nil {
return err
}
if err := out.Sync(); err != nil {
return err
}
return prepareExecutableForUse(dst)
}
func appendExecutableCandidate(candidates []executableCandidate, seen map[string]struct{}, path, source string) []executableCandidate {
cleanedPath := filepath.Clean(strings.TrimSpace(path))
if cleanedPath == "" {
return candidates
}
if _, exists := seen[cleanedPath]; exists {
return candidates
}
seen[cleanedPath] = struct{}{}
return append(candidates, executableCandidate{
path: cleanedPath,
source: source,
})
}
func resolveSystemExecutable(executableName string) string {
if runtime.GOOS == "darwin" {
candidates := []string{
@@ -114,83 +163,163 @@ func resolveSystemExecutable(executableName string) string {
return ""
}
func GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
func runExecutableVersionCheck(path string) error {
cmd := exec.Command(path, "-version")
setHideWindow(cmd)
return cmd.Run()
}
func removeMacOSQuarantineAttribute(path string) error {
cmd := exec.Command("xattr", "-d", "com.apple.quarantine", path)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err == nil {
return nil
}
trimmedOutput := strings.TrimSpace(string(output))
lowerOutput := strings.ToLower(trimmedOutput)
if strings.Contains(lowerOutput, "no such xattr") || strings.Contains(lowerOutput, "attribute not found") {
return nil
}
if trimmedOutput != "" {
return fmt.Errorf("%w: %s", err, trimmedOutput)
}
return err
}
func prepareExecutableForUse(path string) error {
cleanedPath := filepath.Clean(strings.TrimSpace(path))
if cleanedPath == "" {
return fmt.Errorf("empty path")
}
if runtime.GOOS == "windows" {
return nil
}
if err := os.Chmod(cleanedPath, 0755); err != nil {
return fmt.Errorf("failed to mark executable: %w", err)
}
if runtime.GOOS == "darwin" {
if err := removeMacOSQuarantineAttribute(cleanedPath); err != nil {
fmt.Printf("[FFmpeg] Warning: failed to remove macOS quarantine from %s: %v\n", cleanedPath, err)
}
}
return nil
}
func resolveExecutablePath(executableName string) (string, string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", "", err
}
localPath := filepath.Join(ffmpegDir, executableName)
nextDir := filepath.Join(filepath.Dir(ffmpegDir), ".spotiflac-next")
nextPath := filepath.Join(nextDir, executableName)
localExists := false
candidates := make([]executableCandidate, 0, 3)
seen := make(map[string]struct{}, 3)
if systemPath := resolveSystemExecutable(executableName); systemPath != "" {
candidates = appendExecutableCandidate(candidates, seen, systemPath, "system")
}
if _, err := os.Stat(localPath); err == nil {
localExists = true
candidates = appendExecutableCandidate(candidates, seen, localPath, "local")
}
if !localExists {
if _, err := os.Stat(nextPath); err == nil {
if copyErr := copyExecutable(nextPath, localPath); copyErr == nil {
fmt.Printf("[FFmpeg] Copied %s from SpotiFLAC-Next folder\n", executableName)
candidates = appendExecutableCandidate(candidates, seen, localPath, "migrated")
}
}
}
var lastErr error
for _, candidate := range candidates {
if candidate.source != "system" {
if err := prepareExecutableForUse(candidate.path); err != nil {
lastErr = err
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
continue
}
}
if err := ValidateExecutable(candidate.path); err != nil {
lastErr = err
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
continue
}
if err := runExecutableVersionCheck(candidate.path); err != nil {
lastErr = err
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
continue
}
return candidate.path, localPath, nil
}
if len(candidates) > 0 {
if lastErr != nil {
return "", localPath, fmt.Errorf("no working %s executable found: %w", executableName, lastErr)
}
return "", localPath, fmt.Errorf("no working %s executable found", executableName)
}
return "", localPath, fmt.Errorf("%s not found in app directory or system path", executableName)
}
func GetFFmpegPath() (string, error) {
ffmpegName := "ffmpeg"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
}
if path := resolveSystemExecutable(ffmpegName); path != "" {
return path, nil
}
localPath := filepath.Join(ffmpegDir, ffmpegName)
if _, err := os.Stat(localPath); err == nil {
return localPath, nil
}
return localPath, nil
}
func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
path, localPath, err := resolveExecutablePath(ffmpegName)
if err != nil {
if localPath != "" {
return localPath, err
}
return "", err
}
return path, nil
}
func GetFFprobePath() (string, error) {
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffprobeName = "ffprobe.exe"
}
if path := resolveSystemExecutable(ffprobeName); path != "" {
return path, nil
path, localPath, err := resolveExecutablePath(ffprobeName)
if err != nil {
if localPath != "" {
return localPath, err
}
return "", err
}
localPath := filepath.Join(ffmpegDir, ffprobeName)
if _, err := os.Stat(localPath); err == nil {
return localPath, nil
}
return localPath, fmt.Errorf("ffprobe not found in app directory or system path")
return path, nil
}
func IsFFprobeInstalled() (bool, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return false, nil
}
if err := ValidateExecutable(ffprobePath); err != nil {
return false, nil
}
cmd := exec.Command(ffprobePath, "-version")
setHideWindow(cmd)
err = cmd.Run()
_, err := GetFFprobePath()
return err == nil, nil
}
func IsFFmpegInstalled() (bool, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return false, err
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return false, nil
}
cmd := exec.Command(ffmpegPath, "-version")
setHideWindow(cmd)
err = cmd.Run()
if err != nil {
if _, err := GetFFmpegPath(); err != nil {
return false, nil
}
@@ -507,6 +636,10 @@ func extractZip(zipPath, destDir string) error {
return fmt.Errorf("failed to extract file: %w", err)
}
if err := prepareExecutableForUse(destPath); err != nil {
return fmt.Errorf("failed to prepare extracted executable: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
@@ -584,6 +717,10 @@ func extractTarXz(tarXzPath, destDir string) error {
return fmt.Errorf("failed to extract file: %w", err)
}
if err := prepareExecutableForUse(destPath); err != nil {
return fmt.Errorf("failed to prepare extracted executable: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
+5 -1
View File
@@ -1,17 +1,21 @@
package backend
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
const qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
var defaultQobuzStreamAPIBaseURLs = []string{
"https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=",
"https://qobuz.spotbye.qzz.io/api/track/",
}
func GetQobuzStreamAPIBaseURLs() []string {
return append([]string(nil), defaultQobuzStreamAPIBaseURLs...)
}
func GetQobuzMusicDLDownloadAPIURL() string {
return qobuzMusicDLDownloadAPIURL
}
func GetAmazonMusicAPIBaseURL() string {
return amazonMusicAPIBaseURL
}
+214 -10
View File
@@ -1,6 +1,10 @@
package backend
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
@@ -10,6 +14,7 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
@@ -68,6 +73,57 @@ type QobuzStreamResponse struct {
URL string `json:"url"`
}
type qobuzMusicDLRequest struct {
URL string `json:"url"`
Quality string `json:"quality"`
}
type qobuzMusicDLResponse struct {
Success bool `json:"success"`
Type string `json:"type"`
URLType string `json:"url_type"`
TrackID string `json:"track_id"`
Quality string `json:"quality_label"`
DownloadURL string `json:"download_url"`
Message string `json:"message"`
Error string `json:"error"`
}
const qobuzMusicDLProbeTrackID int64 = 341032040
var (
qobuzMusicDLDebugKeyOnce sync.Once
qobuzMusicDLDebugKey string
qobuzMusicDLDebugKeyErr error
)
var qobuzMusicDLDebugKeySeedParts = [][]byte{
{0x73, 0x70, 0x6f, 0x74, 0x69, 0x66},
{0x6c, 0x61, 0x63, 0x3a, 0x71, 0x6f},
{0x62, 0x75, 0x7a, 0x3a, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64, 0x6c, 0x3a, 0x76, 0x31},
}
var qobuzMusicDLDebugKeyAAD = []byte{
0x71, 0x6f, 0x62, 0x75, 0x7a, 0x7c, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64,
0x6c, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31,
}
var qobuzMusicDLDebugKeyNonce = []byte{
0x91, 0x2a, 0x5c, 0x77, 0x0f, 0x33, 0xa8, 0x14, 0x62, 0x9d, 0xce, 0x41,
}
var qobuzMusicDLDebugKeyCiphertext = []byte{
0xf3, 0x4a, 0x83, 0x45, 0x24, 0xb6, 0x22, 0xaf, 0xd6, 0xc3, 0x6e, 0x2d,
0x56, 0xd1, 0xbb, 0x0b, 0xe9, 0x1b, 0x4f, 0x1c, 0x5f, 0x41, 0x55, 0xc2,
0xc6, 0xdf, 0xad, 0x21, 0x58, 0xfe, 0xd5, 0xb8, 0x2d, 0x29, 0xf9, 0x9e,
0x6f, 0xd6,
}
var qobuzMusicDLDebugKeyTag = []byte{
0x69, 0x0c, 0x42, 0x70, 0x14, 0x83, 0xff, 0x14, 0xc8, 0xbe, 0x17, 0x00,
0x69, 0xb1, 0xfe, 0xbb,
}
func NewQobuzDownloader() *QobuzDownloader {
return &QobuzDownloader{
client: &http.Client{
@@ -77,6 +133,57 @@ func NewQobuzDownloader() *QobuzDownloader {
}
}
func previewQobuzResponseBody(body []byte, maxLen int) string {
preview := strings.TrimSpace(string(body))
if len(preview) > maxLen {
return preview[:maxLen] + "..."
}
return preview
}
func buildQobuzOpenTrackURL(trackID int64) string {
return fmt.Sprintf("https://open.qobuz.com/track/%d", trackID)
}
func getQobuzMusicDLDebugKey() (string, error) {
qobuzMusicDLDebugKeyOnce.Do(func() {
hasher := sha256.New()
for _, part := range qobuzMusicDLDebugKeySeedParts {
hasher.Write(part)
}
block, err := aes.NewCipher(hasher.Sum(nil))
if err != nil {
qobuzMusicDLDebugKeyErr = err
return
}
gcm, err := cipher.NewGCM(block)
if err != nil {
qobuzMusicDLDebugKeyErr = err
return
}
sealed := make([]byte, 0, len(qobuzMusicDLDebugKeyCiphertext)+len(qobuzMusicDLDebugKeyTag))
sealed = append(sealed, qobuzMusicDLDebugKeyCiphertext...)
sealed = append(sealed, qobuzMusicDLDebugKeyTag...)
plaintext, err := gcm.Open(nil, qobuzMusicDLDebugKeyNonce, sealed, qobuzMusicDLDebugKeyAAD)
if err != nil {
qobuzMusicDLDebugKeyErr = err
return
}
qobuzMusicDLDebugKey = string(plaintext)
})
if qobuzMusicDLDebugKeyErr != nil {
return "", qobuzMusicDLDebugKeyErr
}
return qobuzMusicDLDebugKey, nil
}
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
if strings.HasPrefix(isrc, "qobuz_") {
trackID := strings.TrimPrefix(isrc, "qobuz_")
@@ -139,9 +246,6 @@ func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
}
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
}
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
}
@@ -188,6 +292,81 @@ func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, qu
return "", fmt.Errorf("invalid response")
}
func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (string, error) {
if strings.TrimSpace(quality) == "" {
quality = "6"
}
debugKey, err := getQobuzMusicDLDebugKey()
if err != nil {
return "", fmt.Errorf("failed to decrypt MusicDL debug key: %w", err)
}
payload, err := json.Marshal(qobuzMusicDLRequest{
URL: buildQobuzOpenTrackURL(trackID),
Quality: strings.TrimSpace(quality),
})
if err != nil {
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
}
req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetQobuzMusicDLDownloadAPIURL(), bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Debug-Key", debugKey)
resp, err := q.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to reach MusicDL: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("MusicDL returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
}
var downloadResp qobuzMusicDLResponse
if err := json.Unmarshal(body, &downloadResp); err != nil {
return "", fmt.Errorf("failed to decode MusicDL response: %w (%s)", err, previewQobuzResponseBody(body, 256))
}
if !downloadResp.Success {
message := strings.TrimSpace(downloadResp.Error)
if message == "" {
message = strings.TrimSpace(downloadResp.Message)
}
if message == "" {
message = "MusicDL reported failure"
}
return "", fmt.Errorf("%s", message)
}
downloadURL := strings.TrimSpace(downloadResp.DownloadURL)
if downloadURL == "" {
return "", fmt.Errorf("MusicDL response did not include a download_url")
}
return downloadURL, nil
}
func CheckQobuzMusicDLStatus(client *http.Client) bool {
if client == nil {
client = &http.Client{Timeout: 4 * time.Second}
}
downloader := &QobuzDownloader{client: client, appID: qobuzDefaultAPIAppID}
_, err := downloader.DownloadFromMusicDL(qobuzMusicDLProbeTrackID, "27")
return err == nil
}
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
qualityCode := quality
if qualityCode == "" || qualityCode == "5" {
@@ -196,8 +375,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs())
downloadFunc := func(qual string) (string, error) {
type Provider struct {
Name string
@@ -205,21 +382,48 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
Func func() (string, error)
}
var providers []Provider
providerMap := make(map[string]Provider)
providerIDs := []string{GetQobuzMusicDLDownloadAPIURL()}
for _, api := range standardAPIs {
providerMap[GetQobuzMusicDLDownloadAPIURL()] = Provider{
Name: "MusicDL",
API: GetQobuzMusicDLDownloadAPIURL(),
Func: func() (string, error) {
return q.DownloadFromMusicDL(trackID, qual)
},
}
for _, api := range GetQobuzStreamAPIBaseURLs() {
currentAPI := api
providers = append(providers, Provider{
providerIDs = append(providerIDs, currentAPI)
providerMap[currentAPI] = Provider{
Name: "Standard(" + currentAPI + ")",
API: currentAPI,
Func: func() (string, error) {
return q.DownloadFromStandard(currentAPI, trackID, qual)
},
})
}
}
orderedProviderIDs := prioritizeProviders("qobuz", providerIDs)
primaryProviderID := GetQobuzMusicDLDownloadAPIURL()
if len(orderedProviderIDs) > 1 && orderedProviderIDs[0] != primaryProviderID {
reordered := []string{primaryProviderID}
for _, providerID := range orderedProviderIDs {
if providerID == primaryProviderID {
continue
}
reordered = append(reordered, providerID)
}
orderedProviderIDs = reordered
}
var lastErr error
for _, p := range providers {
for _, providerID := range orderedProviderIDs {
p, ok := providerMap[providerID]
if !ok {
continue
}
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
url, err := p.Func()
+189 -10
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
@@ -47,10 +48,172 @@ type TidalBTSManifest struct {
URLs []string `json:"urls"`
}
func getConfiguredTidalAPIAttemptList() ([]string, error) {
customAPI := GetCustomTidalAPISetting()
apis, err := GetRotatedTidalAPIList()
if customAPI == "" {
return apis, err
}
if err != nil && len(apis) == 0 {
return []string{customAPI}, nil
}
result := make([]string, 0, len(apis)+1)
result = append(result, customAPI)
for _, apiURL := range apis {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" || apiURL == customAPI {
continue
}
result = append(result, apiURL)
}
return result, err
}
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", false, fmt.Errorf("directory error: %w", err)
}
}
artistNameForFile := sanitizeFilename(spotifyArtistName)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
trackTitleForFile := sanitizeFilename(spotifyTrackName)
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
outputFilename := filepath.Join(outputDir, filename)
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
return outputFilename, alreadyExists, nil
}
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
trackTitle := spotifyTrackName
artistName := spotifyArtistName
albumTitle := spotifyAlbumName
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
if ShouldSkipMusicBrainzMetadataFetch() {
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
} else {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
if isrc == "" {
isrc = result.ISRC
}
mbMeta = result.Metadata
}
upc := ""
if spotifyURL != "" {
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
isrc = strings.TrimSpace(identifiers.ISRC)
}
upc = strings.TrimSpace(identifiers.UPC)
}
}
fmt.Println("Adding metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = outputFilename + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artistName,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Composer: spotifyComposer,
Separator: metadataSeparator,
Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
UPC: upc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
fmt.Printf("Tagging failed: %v\n", err)
} else {
fmt.Println("Metadata saved")
}
}
func NewTidalDownloader(apiURL string) *TidalDownloader {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" {
apis, err := GetRotatedTidalAPIList()
apis, err := getConfiguredTidalAPIAttemptList()
if err == nil && len(apis) > 0 {
apiURL = apis[0]
}
@@ -67,7 +230,7 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
}
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
apis, err := GetRotatedTidalAPIList()
apis, err := getConfiguredTidalAPIAttemptList()
if err == nil && len(apis) > 0 {
return apis, nil
}
@@ -173,10 +336,10 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("download URL not found in response")
}
func (t *TidalDownloader) DownloadFile(url, filepath string) error {
func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) error {
if strings.HasPrefix(url, "MANIFEST:") {
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath, quality)
}
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
@@ -213,12 +376,18 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
return nil
}
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string, quality string) error {
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
if err != nil {
return fmt.Errorf("failed to parse manifest: %w", err)
}
isLosslessRequested := quality == "LOSSLESS" || quality == "HI_RES" || quality == "HI_RES_LOSSLESS"
isActualLossless := strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == ""
if isLosslessRequested && !isActualLossless {
return fmt.Errorf("requested %s quality but Tidal provided lossy format (%s). Aborting download", quality, mimeType)
}
client := &http.Client{
Timeout: 120 * time.Second,
}
@@ -433,7 +602,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
}
fmt.Printf("Downloading to: %s\n", outputFilename)
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
if err := t.DownloadFile(downloadURL, outputFilename, quality); err != nil {
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
@@ -493,6 +662,10 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
}
if t.apiURL != "" {
return t.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
}
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
}
@@ -550,10 +723,12 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
var mpd MPD
var segTemplate *SegmentTemplate
var dashMimeType string
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
var selectedBandwidth int
var selectedCodecs string
var selectedMimeType string
for _, as := range mpd.Period.AdaptationSets {
@@ -562,6 +737,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if segTemplate == nil {
segTemplate = as.SegmentTemplate
selectedCodecs = as.Codecs
selectedMimeType = as.MimeType
}
}
@@ -576,6 +752,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
} else {
selectedCodecs = as.Codecs
}
selectedMimeType = as.MimeType
}
}
}
@@ -583,6 +761,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if selectedBandwidth > 0 {
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
dashMimeType = fmt.Sprintf("%s; codecs=\"%s\"", selectedMimeType, selectedCodecs)
}
}
@@ -608,7 +787,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL)
}
return "", initURL, mediaURLs, "", nil
return "", initURL, mediaURLs, dashMimeType, nil
}
fmt.Println("Using regex fallback for DASH manifest...")
@@ -655,7 +834,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURLs = append(mediaURLs, mediaURL)
}
return "", initURL, mediaURLs, "", nil
return "", initURL, mediaURLs, dashMimeType, nil
}
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
@@ -684,7 +863,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename
}
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
apis, err := GetRotatedTidalAPIList()
apis, err := getConfiguredTidalAPIAttemptList()
if err != nil && len(apis) == 0 {
return "", fmt.Errorf("failed to load tidal api list: %w", err)
}
@@ -706,7 +885,7 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena
continue
}
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
if err := downloader.DownloadFile(downloadURL, outputFilename, quality); err != nil {
lastErr = err
cleanupTidalDownloadArtifacts(outputFilename)
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
-238
View File
@@ -1,238 +0,0 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const tidalAltDownloadAPIBaseURL = "https://tidal.spotbye.qzz.io/get"
type TidalAltAPIResponse struct {
Title string `json:"title"`
Link string `json:"link"`
}
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", false, fmt.Errorf("directory error: %w", err)
}
}
artistNameForFile := sanitizeFilename(spotifyArtistName)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
trackTitleForFile := sanitizeFilename(spotifyTrackName)
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
outputFilename := filepath.Join(outputDir, filename)
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
return outputFilename, alreadyExists, nil
}
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
trackTitle := spotifyTrackName
artistName := spotifyArtistName
albumTitle := spotifyAlbumName
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
if ShouldSkipMusicBrainzMetadataFetch() {
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
} else {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
if isrc == "" {
isrc = result.ISRC
}
mbMeta = result.Metadata
}
upc := ""
if spotifyURL != "" {
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
isrc = strings.TrimSpace(identifiers.ISRC)
}
upc = strings.TrimSpace(identifiers.UPC)
}
}
fmt.Println("Adding metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = outputFilename + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artistName,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Composer: spotifyComposer,
Separator: metadataSeparator,
Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
UPC: upc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
fmt.Printf("Tagging failed: %v\n", err)
} else {
fmt.Println("Metadata saved")
}
}
func (t *TidalDownloader) GetAltDownloadURLFromSpotify(spotifyTrackID string) (string, error) {
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required")
}
apiURL := fmt.Sprintf("%s/%s", tidalAltDownloadAPIBaseURL, spotifyTrackID)
fmt.Printf("Tidal Alt. API URL: %s\n", apiURL)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create Tidal Alt. request: %w", err)
}
resp, err := t.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Tidal Alt. download URL: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Tidal Alt. response: %w", err)
}
if resp.StatusCode != http.StatusOK {
preview := strings.TrimSpace(string(body))
if len(preview) > 200 {
preview = preview[:200] + "..."
}
return "", fmt.Errorf("Tidal Alt. returned status %d: %s", resp.StatusCode, preview)
}
var payload TidalAltAPIResponse
if err := json.Unmarshal(body, &payload); err != nil {
return "", fmt.Errorf("failed to decode Tidal Alt. response: %w", err)
}
downloadURL := strings.TrimSpace(payload.Link)
if downloadURL == "" {
return "", fmt.Errorf("Tidal Alt. response did not include a download link")
}
fmt.Println("✓ Tidal Alt. download URL found")
return downloadURL, nil
}
func (t *TidalDownloader) DownloadAlt(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required for Tidal Alt.")
}
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
if err != nil {
return "", err
}
if alreadyExists {
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
return "EXISTS:" + outputFilename, nil
}
fmt.Printf("Using Tidal Alt. for Spotify track: %s\n", spotifyTrackID)
downloadURL, err := t.GetAltDownloadURLFromSpotify(spotifyTrackID)
if err != nil {
return outputFilename, err
}
fmt.Printf("Downloading to: %s\n", outputFilename)
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Tidal Alt.")
return outputFilename, nil
}