v7.1.6
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user