This commit is contained in:
429Enjoyer
2026-06-09 06:06:52 +07:00
parent 31e9ecac35
commit 954cfe9d4f
53 changed files with 2910 additions and 912 deletions
+130 -180
View File
@@ -1,9 +1,7 @@
package backend
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"bytes"
"encoding/json"
"fmt"
"io"
@@ -13,7 +11,6 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
@@ -22,81 +19,6 @@ type AmazonDownloader struct {
regions []string
}
type AmazonStreamResponse struct {
StreamURL string `json:"streamUrl"`
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{
@@ -122,7 +44,29 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
return amazonURL, nil
}
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
type amazonCommunityResponse struct {
ASIN string `json:"asin"`
Codec string `json:"codec"`
BitDepth int `json:"bit_depth"`
URL string `json:"url"`
StreamURL string `json:"stream_url"`
Key string `json:"key"`
KeySpecs []string `json:"key_specs"`
Captcha string `json:"captcha"`
}
func amazonCommunityNormalizeQuality(quality string) string {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "16", "lossless", "cd":
return "16"
case "atmos", "eac3", "dolby":
return "atmos"
default:
return "24"
}
}
func (a *AmazonDownloader) downloadFromCommunity(amazonURL, outputDir, quality string) (string, error) {
asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
asin := asinRegex.FindString(amazonURL)
@@ -130,20 +74,28 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
}
apiURL := fmt.Sprintf("%s/api/track/%s", amazonMusicAPIBaseURL, asin)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
payload, err := json.Marshal(map[string]string{
"id": asin,
"quality": amazonCommunityNormalizeQuality(quality),
"country": "US",
})
if err != nil {
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)
resp, err := doCommunityRequest(a.client, "Amazon", func() (*http.Request, error) {
req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetAmazonCommunityDownloadURL(), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if err := setCommunityRequestHeaders(req); err != nil {
return nil, err
}
return req, nil
})
if err != nil {
return "", err
}
@@ -158,29 +110,43 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
return "", err
}
var apiResp AmazonStreamResponse
var apiResp amazonCommunityResponse
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if apiResp.StreamURL == "" {
streamURL := strings.TrimSpace(apiResp.StreamURL)
if streamURL == "" {
streamURL = strings.TrimSpace(apiResp.URL)
}
if streamURL == "" {
return "", fmt.Errorf("no stream URL found in response")
}
downloadURL := apiResp.StreamURL
fileName := fmt.Sprintf("%s.m4a", asin)
filePath := filepath.Join(outputDir, fileName)
keySpecs := apiResp.KeySpecs
if len(keySpecs) == 0 {
if key := strings.TrimSpace(apiResp.Key); key != "" {
keySpecs = []string{key}
}
}
out, err := os.Create(filePath)
encryptedPath := filepath.Join(outputDir, fmt.Sprintf("%s.encrypted.mp4", asin))
out, err := os.Create(encryptedPath)
if err != nil {
return "", err
}
defer out.Close()
defer func() {
out.Close()
os.Remove(encryptedPath)
}()
dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, downloadURL, nil)
dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, streamURL, nil)
if err != nil {
return "", err
}
if captcha := strings.TrimSpace(apiResp.Captcha); captcha != "" {
dlReq.Header.Set("x-captcha-token", captcha)
}
dlResp, err := a.client.Do(dlReq)
if err != nil {
@@ -188,101 +154,85 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
}
defer dlResp.Body.Close()
fmt.Printf("Downloading track: %s\n", fileName)
fmt.Printf("Downloading track: %s\n", asin)
pw := NewProgressWriter(out)
_, err = io.Copy(pw, dlResp.Body)
if err != nil {
out.Close()
os.Remove(filePath)
if _, err = io.Copy(pw, dlResp.Body); err != nil {
return "", err
}
out.Close()
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
if apiResp.DecryptionKey != "" {
remuxInput := encryptedPath
if len(keySpecs) > 0 {
fmt.Printf("Decrypting file...\n")
ffprobePath, err := GetFFprobePath()
var codec string
if err == nil {
cmdProbe := exec.Command(ffprobePath,
"-v", "quiet",
"-select_streams", "a:0",
"-show_entries", "stream=codec_name",
"-of", "default=noprint_wrappers=1:nokey=1",
filePath,
)
setHideWindow(cmdProbe)
codecOutput, _ := cmdProbe.Output()
codec = strings.TrimSpace(string(codecOutput))
fmt.Printf("Detected codec: %s\n", codec)
decryptedPath := filepath.Join(outputDir, fmt.Sprintf("%s.decrypted.mp4", asin))
if err := decryptWithMP4FF(keySpecs, encryptedPath, decryptedPath); err != nil {
return "", err
}
targetExt := ".m4a"
if codec == "flac" {
targetExt = ".flac"
}
decryptedFilename := "dec_" + fileName + targetExt
if targetExt == ".flac" && strings.HasSuffix(fileName, ".m4a") {
decryptedFilename = "dec_" + strings.TrimSuffix(fileName, ".m4a") + ".flac"
}
decryptedPath := filepath.Join(outputDir, decryptedFilename)
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return "", fmt.Errorf("ffmpeg not found for decryption: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return "", fmt.Errorf("invalid ffmpeg executable: %w", err)
}
key := strings.TrimSpace(apiResp.DecryptionKey)
cmd := exec.Command(ffmpegPath,
"-decryption_key", key,
"-i", filePath,
"-c", "copy",
"-y",
decryptedPath,
)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
outStr := string(output)
if len(outStr) > 500 {
outStr = outStr[len(outStr)-500:]
}
return "", fmt.Errorf("ffmpeg decryption failed: %v\nTail Output: %s", err, outStr)
}
if info, err := os.Stat(decryptedPath); err != nil || info.Size() == 0 {
return "", fmt.Errorf("decrypted file missing or empty")
}
if err := os.Remove(filePath); err != nil {
fmt.Printf("Warning: Failed to remove encrypted file: %v\n", err)
}
finalPath := filepath.Join(outputDir, strings.TrimPrefix(decryptedFilename, "dec_"))
if err := os.Rename(decryptedPath, finalPath); err != nil {
return "", fmt.Errorf("failed to rename decrypted file: %w", err)
}
filePath = finalPath
defer os.Remove(decryptedPath)
remuxInput = decryptedPath
fmt.Println("Decryption successful")
}
return filePath, nil
targetExt := ".flac"
if codec := strings.ToLower(strings.TrimSpace(apiResp.Codec)); codec == "eac3" || codec == "ec-3" || codec == "ac-3" {
targetExt = ".m4a"
}
finalPath := filepath.Join(outputDir, asin+targetExt)
if err := amazonRemuxWithFFmpeg(remuxInput, finalPath, targetExt); err != nil {
return "", err
}
if info, err := os.Stat(finalPath); err != nil || info.Size() == 0 {
return "", fmt.Errorf("remuxed file missing or empty")
}
return finalPath, nil
}
func amazonRemuxWithFFmpeg(inputPath, outputPath, targetExt string) error {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return fmt.Errorf("ffmpeg not found for remux: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return fmt.Errorf("invalid ffmpeg executable: %w", err)
}
runFFmpeg := func(args ...string) (string, error) {
cmd := exec.Command(ffmpegPath, args...)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
return string(output), err
}
args := []string{"-y", "-i", inputPath, "-map", "0:a:0", "-vn", "-c:a", "copy"}
if targetExt == ".m4a" {
args = append(args, "-f", "mp4")
}
args = append(args, outputPath)
if output, err := runFFmpeg(args...); err != nil {
if targetExt == ".flac" {
if output2, err2 := runFFmpeg("-y", "-i", inputPath, "-map", "0:a:0", "-vn", "-c:a", "flac", outputPath); err2 == nil {
return nil
} else {
output = output2
err = err2
}
}
if len(output) > 500 {
output = output[len(output)-500:]
}
return fmt.Errorf("ffmpeg remux failed: %v\nTail Output: %s", err, output)
}
return nil
}
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
return a.downloadFromCommunity(amazonURL, outputDir, quality)
}
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
@@ -339,7 +289,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("MusicBrainz metadata fetched")
fmt.Println("MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
@@ -520,7 +470,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
}
fmt.Println("Done")
fmt.Println("Downloaded successfully from Amazon Music")
fmt.Println("Downloaded successfully from Amazon Music")
return filePath, nil
}
+97
View File
@@ -0,0 +1,97 @@
package backend
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"fmt"
"net/http"
"strings"
"sync"
)
var (
communityAPIKeyOnce sync.Once
communityAPIKey string
communityAPIKeyErr error
)
var communityAPIKeySeedParts = [][]byte{
[]byte("spotif"),
[]byte("lac:co"),
[]byte("mmunity:apikey:v1"),
}
var communityAPIKeyAAD = []byte("spotiflac|community|apikey|v1")
var communityAPIKeyNonce = []byte{
0x20, 0x5c, 0x92, 0x4b, 0x61, 0xc2, 0x79, 0xd3, 0xea, 0x5d, 0xdd, 0xd4,
}
var communityAPIKeyCiphertext = []byte{
0x51, 0x0b, 0x26, 0xaf, 0xac, 0x6f, 0xf6, 0x41, 0x79, 0xde, 0x8d, 0x36,
0x83, 0x46, 0xb5, 0xd5, 0x96, 0xef, 0xad, 0xed, 0xe0, 0xd0, 0xc7, 0xc2,
0x90, 0x01, 0x50, 0x5f, 0x55, 0x59, 0x9f, 0xac, 0x1f, 0xd0, 0x70, 0x18,
0x91, 0x4f, 0x7a, 0x32,
}
var communityAPIKeyTag = []byte{
0x56, 0xb0, 0x28, 0x68, 0x9f, 0x39, 0x0d, 0xbc, 0xc0, 0x8e, 0xfb, 0x52,
0x3a, 0xd6, 0x18, 0xae,
}
func getCommunityAPIKey() (string, error) {
communityAPIKeyOnce.Do(func() {
hasher := sha256.New()
for _, part := range communityAPIKeySeedParts {
hasher.Write(part)
}
block, err := aes.NewCipher(hasher.Sum(nil))
if err != nil {
communityAPIKeyErr = err
return
}
gcm, err := cipher.NewGCM(block)
if err != nil {
communityAPIKeyErr = err
return
}
sealed := make([]byte, 0, len(communityAPIKeyCiphertext)+len(communityAPIKeyTag))
sealed = append(sealed, communityAPIKeyCiphertext...)
sealed = append(sealed, communityAPIKeyTag...)
plaintext, err := gcm.Open(nil, communityAPIKeyNonce, sealed, communityAPIKeyAAD)
if err != nil {
communityAPIKeyErr = err
return
}
communityAPIKey = string(plaintext)
})
if communityAPIKeyErr != nil {
return "", communityAPIKeyErr
}
return communityAPIKey, nil
}
func communityUserAgent() string {
version := strings.TrimSpace(AppVersion)
if version == "" || version == "Unknown" {
return "SpotiFLAC"
}
return "SpotiFLAC/" + version
}
func setCommunityRequestHeaders(req *http.Request) error {
apiKey, err := getCommunityAPIKey()
if err != nil {
return fmt.Errorf("failed to prepare community API key: %w", err)
}
req.Header.Set("x-api-key", apiKey)
req.Header.Set("User-Agent", communityUserAgent())
return nil
}
+180
View File
@@ -0,0 +1,180 @@
package backend
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const communityDownloadPath = "/api/dl"
var communityURLSeedParts = [][]byte{
[]byte("spotif"),
[]byte("lac:co"),
[]byte("mmunity:url:v1"),
}
var communityURLAAD = []byte("spotiflac|community|url|v1")
var (
tidalCommunityURLNonce = []byte{
0x6a, 0x2a, 0x9e, 0xf3, 0x25, 0x5f, 0x48, 0x3c, 0xc3, 0xdf, 0x1d, 0xa9,
}
tidalCommunityURLCiphertext = []byte{
0x8f, 0x90, 0xa4, 0x28, 0x24, 0x06, 0x35, 0x13, 0x2d, 0x33, 0x96, 0x9a,
0xd7, 0x2c, 0x31, 0x42, 0x6a, 0xf3, 0xee, 0x86, 0x34, 0x99, 0x15, 0x1e,
0xa9, 0x07, 0x06, 0xe6, 0xee, 0x0d, 0x75,
}
tidalCommunityURLTag = []byte{
0x4d, 0x1c, 0x4e, 0x98, 0x96, 0x07, 0x16, 0xad, 0x6a, 0x7c, 0xa0, 0xdf,
0xe9, 0xc5, 0xf6, 0x87,
}
qobuzCommunityURLNonce = []byte{
0x5f, 0xd8, 0xfd, 0xfd, 0x89, 0x83, 0xe7, 0x6c, 0xde, 0x48, 0x47, 0x8d,
}
qobuzCommunityURLCiphertext = []byte{
0xfa, 0x35, 0x21, 0xba, 0x02, 0xc6, 0x15, 0x1f, 0x0e, 0xa3, 0xa6, 0x16,
0x64, 0x2b, 0xd8, 0xfb, 0xf5, 0x35, 0xfe, 0xe9, 0x0e, 0x59, 0xd9, 0x25,
0x72, 0x57, 0x88, 0x94, 0xa9, 0xb7, 0x70,
}
qobuzCommunityURLTag = []byte{
0xd7, 0x72, 0xb5, 0x2b, 0x1c, 0xb1, 0xfd, 0xba, 0x22, 0x09, 0x25, 0x41,
0x87, 0x85, 0x30, 0x1b,
}
amazonCommunityURLNonce = []byte{
0x55, 0x18, 0x01, 0x42, 0x42, 0x0c, 0xf6, 0x78, 0x8a, 0x73, 0xd7, 0x63,
}
amazonCommunityURLCiphertext = []byte{
0xd2, 0xf3, 0xdc, 0xe8, 0x62, 0xf0, 0xad, 0xc2, 0x4a, 0x43, 0xb1, 0xa2,
0x1c, 0x0d, 0x41, 0x3e, 0x2e, 0x30, 0x29, 0x5e, 0x46, 0xe2, 0xc2, 0xd6,
0xc1, 0xf3, 0xe3, 0x1a, 0x8f, 0x67, 0xfe,
}
amazonCommunityURLTag = []byte{
0xf9, 0x0a, 0xfd, 0xed, 0x9e, 0xe8, 0xb4, 0xc0, 0x75, 0xf3, 0xd5, 0x74,
0x3c, 0xb6, 0xa1, 0xb9,
}
)
var (
communityURLGCMOnce sync.Once
communityURLGCM cipher.AEAD
communityURLGCMErr error
)
func communityURLCipher() (cipher.AEAD, error) {
communityURLGCMOnce.Do(func() {
hasher := sha256.New()
for _, part := range communityURLSeedParts {
hasher.Write(part)
}
block, err := aes.NewCipher(hasher.Sum(nil))
if err != nil {
communityURLGCMErr = err
return
}
gcm, err := cipher.NewGCM(block)
if err != nil {
communityURLGCMErr = err
return
}
communityURLGCM = gcm
})
return communityURLGCM, communityURLGCMErr
}
func decryptCommunityURL(nonce, ciphertext, tag []byte) (string, error) {
gcm, err := communityURLCipher()
if err != nil {
return "", err
}
sealed := make([]byte, 0, len(ciphertext)+len(tag))
sealed = append(sealed, ciphertext...)
sealed = append(sealed, tag...)
plaintext, err := gcm.Open(nil, nonce, sealed, communityURLAAD)
if err != nil {
return "", err
}
return string(plaintext), nil
}
const communityRateLimitMaxRetries = 6
const communityRateLimitFallbackWait = 30 * time.Second
func GetTidalCommunityDownloadURL() string {
base, _ := decryptCommunityURL(tidalCommunityURLNonce, tidalCommunityURLCiphertext, tidalCommunityURLTag)
return base + communityDownloadPath
}
func GetQobuzCommunityDownloadURL() string {
base, _ := decryptCommunityURL(qobuzCommunityURLNonce, qobuzCommunityURLCiphertext, qobuzCommunityURLTag)
return base + communityDownloadPath
}
func GetAmazonCommunityDownloadURL() string {
base, _ := decryptCommunityURL(amazonCommunityURLNonce, amazonCommunityURLCiphertext, amazonCommunityURLTag)
return base + communityDownloadPath
}
func communityRetryAfter(resp *http.Response) time.Duration {
if resp == nil {
return communityRateLimitFallbackWait
}
if ra := strings.TrimSpace(resp.Header.Get("Retry-After")); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs >= 0 {
return time.Duration(secs)*time.Second + 250*time.Millisecond
}
}
if reset := strings.TrimSpace(resp.Header.Get("X-RateLimit-Reset")); reset != "" {
if epoch, err := strconv.ParseInt(reset, 10, 64); err == nil {
if wait := time.Until(time.Unix(epoch, 0)); wait > 0 {
return wait + 250*time.Millisecond
}
}
}
return communityRateLimitFallbackWait
}
func doCommunityRequest(client *http.Client, service string, reqFn func() (*http.Request, error)) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= communityRateLimitMaxRetries; attempt++ {
req, err := reqFn()
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusTooManyRequests {
ClearRateLimitCooldown()
return resp, nil
}
wait := communityRetryAfter(resp)
resp.Body.Close()
lastErr = fmt.Errorf("%s community API rate limited (429)", service)
if attempt == communityRateLimitMaxRetries {
break
}
fmt.Printf("%s rate limited, waiting %.0fs before retry (%d/%d)...\n", service, wait.Seconds(), attempt+1, communityRateLimitMaxRetries)
SetRateLimitCooldown(wait.Seconds())
if sleepErr := SleepWithDownloadContext(wait); sleepErr != nil {
ClearRateLimitCooldown()
return nil, sleepErr
}
ClearRateLimitCooldown()
}
return nil, lastErr
}
+129
View File
@@ -0,0 +1,129 @@
package backend
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
var ErrDownloadCancelled = errors.New("download cancelled")
var downloadCancelState = struct {
sync.Mutex
ctx context.Context
cancel context.CancelFunc
active int
stopping bool
}{}
func BeginDownloadCancellationScope() (context.Context, func()) {
downloadCancelState.Lock()
defer downloadCancelState.Unlock()
if downloadCancelState.ctx == nil || downloadCancelState.active == 0 {
downloadCancelState.ctx, downloadCancelState.cancel = context.WithCancel(context.Background())
downloadCancelState.stopping = false
}
downloadCancelState.active++
ctx := downloadCancelState.ctx
once := sync.Once{}
return ctx, func() {
once.Do(func() {
downloadCancelState.Lock()
defer downloadCancelState.Unlock()
if downloadCancelState.active > 0 {
downloadCancelState.active--
}
if downloadCancelState.active == 0 {
if downloadCancelState.cancel != nil {
downloadCancelState.cancel()
}
downloadCancelState.ctx = nil
downloadCancelState.cancel = nil
downloadCancelState.stopping = false
}
})
}
}
func ActiveDownloadContext() context.Context {
downloadCancelState.Lock()
defer downloadCancelState.Unlock()
if downloadCancelState.ctx == nil {
return context.Background()
}
return downloadCancelState.ctx
}
func ForceStopActiveDownloads() {
downloadCancelState.Lock()
cancel := downloadCancelState.cancel
if cancel != nil {
downloadCancelState.stopping = true
}
downloadCancelState.Unlock()
if cancel != nil {
cancel()
}
CancelQueuedAndDownloadingItems()
SetDownloading(false)
}
func IsDownloadForceStopRequested() bool {
downloadCancelState.Lock()
defer downloadCancelState.Unlock()
return downloadCancelState.stopping
}
func CheckDownloadCancelled() error {
ctx := ActiveDownloadContext()
select {
case <-ctx.Done():
return ErrDownloadCancelled
default:
return nil
}
}
func SleepWithDownloadContext(delay time.Duration) error {
if delay <= 0 {
return CheckDownloadCancelled()
}
ctx := ActiveDownloadContext()
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ErrDownloadCancelled
case <-timer.C:
return nil
}
}
func IsDownloadCancelledError(err error) bool {
if err == nil {
return false
}
return errors.Is(err, ErrDownloadCancelled) || errors.Is(err, context.Canceled)
}
func WrapDownloadCancelled(err error) error {
if err == nil {
return nil
}
if IsDownloadForceStopRequested() || errors.Is(err, context.Canceled) {
return fmt.Errorf("%w", ErrDownloadCancelled)
}
return err
}
+39 -1
View File
@@ -11,6 +11,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
@@ -373,7 +374,7 @@ func InstallFFmpegWithBrew(progressCallback func(int, string)) error {
return nil
}
const ffmpegReleaseBaseURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.1"
const ffmpegReleaseBaseURL = "https://github.com/spotbye/Dependencies/releases/download/FFmpeg-8.1"
func buildFFmpegReleaseURL(assetName string) string {
return ffmpegReleaseBaseURL + "/" + assetName
@@ -870,6 +871,36 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
"-map", "0:a",
)
}
case "wav", "aiff":
sampleFmt, rawBits := pcmSampleFormatForInput(inputFile)
pcmCodec := "pcm_s16le"
if req.OutputFormat == "aiff" {
pcmCodec = "pcm_s16be"
}
if sampleFmt == "s32" {
if req.OutputFormat == "aiff" {
pcmCodec = "pcm_s24be"
} else {
pcmCodec = "pcm_s24le"
}
}
args = append(args,
"-codec:a", pcmCodec,
"-map", "0:a",
)
if rawBits > 0 {
args = append(args, "-bits_per_raw_sample", strconv.Itoa(rawBits))
}
case "opus":
bitrate := req.Bitrate
if bitrate == "" {
bitrate = "192k"
}
args = append(args,
"-codec:a", "libopus",
"-b:a", bitrate,
"-map", "0:a",
)
}
args = append(args, outputFile)
@@ -924,6 +955,13 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
return results, nil
}
func pcmSampleFormatForInput(inputFile string) (sampleFmt string, rawBits int) {
if meta, err := GetTrackMetadata(inputFile); err == nil && meta != nil && meta.BitsPerSample > 16 {
return "s32", 24
}
return "s16", 0
}
type AudioFileInfo struct {
Path string `json:"path"`
Filename string `json:"filename"`
+9 -8
View File
@@ -149,14 +149,15 @@ func ClearHistory(appName string) error {
}
type FetchHistoryItem struct {
ID string `json:"id"`
URL string `json:"url"`
Type string `json:"type"`
Name string `json:"name"`
Info string `json:"info"`
Image string `json:"image"`
Data string `json:"data"`
Timestamp int64 `json:"timestamp"`
ID string `json:"id"`
URL string `json:"url"`
Type string `json:"type"`
Name string `json:"name"`
Info string `json:"info"`
Image string `json:"image"`
Data string `json:"data"`
IsExplicit bool `json:"is_explicit,omitempty"`
Timestamp int64 `json:"timestamp"`
}
const (
+277
View File
@@ -0,0 +1,277 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type EmbeddedLyrics struct {
Path string `json:"path"`
Name string `json:"name"`
Lyrics string `json:"lyrics"`
Source string `json:"source"`
Synced bool `json:"synced"`
Error string `json:"error,omitempty"`
}
var lrcTimestampRe = regexp.MustCompile(`\[\d{1,2}:\d{2}(?:\.\d{1,3})?\]`)
func isSyncedLyrics(lyrics string) bool {
return lrcTimestampRe.MatchString(lyrics)
}
func ReadEmbeddedLyrics(filePath string) (*EmbeddedLyrics, error) {
if !fileExists(filePath) {
return nil, fmt.Errorf("file does not exist")
}
result := &EmbeddedLyrics{
Path: filePath,
Name: filepath.Base(filePath),
}
ext := strings.ToLower(filepath.Ext(filePath))
var lyrics string
var err error
switch ext {
case ".lrc", ".txt":
var content []byte
content, err = os.ReadFile(filePath)
if err == nil {
lyrics = string(content)
result.Source = "lrc"
}
case ".flac":
lyrics, err = readFlacLyrics(filePath)
result.Source = "embedded"
case ".mp3":
lyrics, err = readMp3Lyrics(filePath)
result.Source = "embedded"
case ".m4a", ".aac", ".opus", ".ogg":
lyrics, err = readLyricsWithFFprobe(filePath)
result.Source = "embedded"
default:
return nil, fmt.Errorf("unsupported file format: %s", ext)
}
if err != nil {
result.Error = err.Error()
return result, nil
}
lyrics = strings.TrimSpace(lyrics)
if lyrics == "" {
result.Error = "No lyrics found in this file"
return result, nil
}
result.Lyrics = lyrics
result.Synced = isSyncedLyrics(lyrics)
return result, nil
}
func readFlacLyrics(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, block := range f.Meta {
if block.Type != flac.VorbisComment {
continue
}
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) != 2 {
continue
}
fieldName := strings.ToUpper(parts[0])
switch fieldName {
case "LYRICS", "UNSYNCEDLYRICS", "SYNCEDLYRICS", "LYRICS-XXX":
if strings.TrimSpace(parts[1]) != "" {
return parts[1], nil
}
}
}
}
return "", nil
}
func readMp3Lyrics(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return "", fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
frames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
for _, frame := range frames {
uslf, ok := frame.(id3v2.UnsynchronisedLyricsFrame)
if !ok {
continue
}
if strings.TrimSpace(uslf.Lyrics) != "" {
return uslf.Lyrics, nil
}
}
return "", nil
}
func readLyricsWithFFprobe(filePath string) (string, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return "", err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return "", fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
filePath,
)
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return "", err
}
var probe struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &probe); err != nil {
return "", err
}
collect := func(tags map[string]string) string {
for key, value := range tags {
lk := strings.ToLower(key)
if lk == "lyrics" || strings.HasPrefix(lk, "lyrics-") || lk == "unsyncedlyrics" {
if strings.TrimSpace(value) != "" {
return value
}
}
}
return ""
}
if lyrics := collect(probe.Format.Tags); lyrics != "" {
return lyrics, nil
}
for _, stream := range probe.Streams {
if lyrics := collect(stream.Tags); lyrics != "" {
return lyrics, nil
}
}
return "", nil
}
type ExtractLyricsResult struct {
Path string `json:"path"`
OutputPath string `json:"output_path,omitempty"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
func ExtractLyricsToLRC(filePath string, overwrite bool) (*ExtractLyricsResult, error) {
result := &ExtractLyricsResult{Path: filePath}
embedded, err := ReadEmbeddedLyrics(filePath)
if err != nil {
result.Error = err.Error()
return result, nil
}
if embedded.Error != "" {
result.Error = embedded.Error
return result, nil
}
if strings.TrimSpace(embedded.Lyrics) == "" {
result.Error = "No lyrics found in this file"
return result, nil
}
dir := filepath.Dir(filePath)
base := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
outputPath := filepath.Join(dir, base+".lrc")
result.OutputPath = outputPath
if !overwrite {
if info, statErr := os.Stat(outputPath); statErr == nil && info.Size() > 0 {
result.AlreadyExists = true
result.Error = "LRC file already exists"
return result, nil
}
}
content := embedded.Lyrics
if !strings.HasSuffix(content, "\n") {
content += "\n"
}
if writeErr := os.WriteFile(outputPath, []byte(content), 0644); writeErr != nil {
result.Error = fmt.Sprintf("failed to write LRC file: %v", writeErr)
return result, nil
}
result.Success = true
return result, nil
}
func SelectLyricsFiles(ctx context.Context) ([]string, error) {
return runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Lyrics or Audio Files",
Filters: []runtime.FileFilter{
{
DisplayName: "Lyrics & Audio (*.lrc, *.flac, *.mp3, *.m4a, *.opus)",
Pattern: "*.lrc;*.flac;*.mp3;*.m4a;*.aac;*.opus;*.ogg;*.txt",
},
{
DisplayName: "LRC Files (*.lrc)",
Pattern: "*.lrc",
},
{
DisplayName: "Audio Files (*.flac, *.mp3, *.m4a, *.opus)",
Pattern: "*.flac;*.mp3;*.m4a;*.aac;*.opus;*.ogg",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
})
}
+247
View File
@@ -0,0 +1,247 @@
package backend
import (
"encoding/hex"
"fmt"
"io"
"os"
"strings"
"github.com/Eyevinn/mp4ff/mp4"
)
func decryptWithMP4FF(keySpecs []string, inputPath, outputPath string) error {
key, keysByKID, strictKIDMode, err := parseMP4FFKeySpecs(keySpecs)
if err != nil {
return err
}
inFile, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("failed to open encrypted MP4: %w", err)
}
defer inFile.Close()
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create decrypted MP4: %w", err)
}
outClosed := false
defer func() {
if !outClosed {
_ = outFile.Close()
}
}()
if err := decryptMP4FFFileWithKeyMap(inFile, nil, outFile, key, keysByKID, strictKIDMode); err != nil {
_ = outFile.Close()
outClosed = true
_ = os.Remove(outputPath)
return fmt.Errorf("mp4ff decryption failed: %w", err)
}
if err := outFile.Close(); err != nil {
outClosed = true
_ = os.Remove(outputPath)
return fmt.Errorf("failed to finalize decrypted MP4: %w", err)
}
outClosed = true
return nil
}
func parseMP4FFKeySpecs(keySpecs []string) (key []byte, keysByKID map[string][]byte, strictKIDMode bool, err error) {
normalizedSpecs := make([]string, 0, len(keySpecs))
seenSpecs := make(map[string]struct{}, len(keySpecs))
for _, spec := range keySpecs {
normalized, err := normalizeMP4FFKeySpec(spec)
if err != nil {
return nil, nil, false, err
}
if normalized == "" {
continue
}
if _, ok := seenSpecs[normalized]; ok {
continue
}
seenSpecs[normalized] = struct{}{}
normalizedSpecs = append(normalizedSpecs, normalized)
}
if len(normalizedSpecs) == 0 {
return nil, nil, false, fmt.Errorf("no mp4ff key specs provided")
}
hasKIDPair := false
hasLegacyKey := false
for _, spec := range normalizedSpecs {
if strings.Contains(spec, ":") {
hasKIDPair = true
} else {
hasLegacyKey = true
}
}
if hasKIDPair && hasLegacyKey {
return nil, nil, false, fmt.Errorf("cannot mix legacy key and kid:key key format")
}
if !hasKIDPair {
if len(normalizedSpecs) != 1 {
return nil, nil, false, fmt.Errorf("multiple legacy keys are not supported")
}
key, err = mp4.UnpackKey(normalizedSpecs[0])
if err != nil {
return nil, nil, false, fmt.Errorf("unpacking key: %w", err)
}
return key, nil, false, nil
}
keysByKID = make(map[string][]byte, len(normalizedSpecs))
for _, spec := range normalizedSpecs {
parts := strings.SplitN(spec, ":", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return nil, nil, false, fmt.Errorf("bad kid:key format %q", spec)
}
kid, err := mp4.UnpackKey(strings.TrimSpace(parts[0]))
if err != nil {
return nil, nil, false, fmt.Errorf("unpacking kid: %w", err)
}
kidHex := hex.EncodeToString(kid)
if _, exists := keysByKID[kidHex]; exists {
return nil, nil, false, fmt.Errorf("duplicate kid %s", kidHex)
}
parsedKey, err := mp4.UnpackKey(strings.TrimSpace(parts[1]))
if err != nil {
return nil, nil, false, fmt.Errorf("unpacking key for kid %s: %w", kidHex, err)
}
keysByKID[kidHex] = parsedKey
}
return nil, keysByKID, true, nil
}
func normalizeMP4FFKeySpec(spec string) (string, error) {
spec = strings.TrimSpace(spec)
if spec == "" || !strings.Contains(spec, ":") {
return spec, nil
}
parts := strings.SplitN(spec, ":", 2)
left := strings.TrimSpace(parts[0])
right := strings.TrimSpace(parts[1])
if left == "" || right == "" {
return "", fmt.Errorf("bad key spec %q", spec)
}
if _, err := mp4.UnpackKey(left); err == nil {
return left + ":" + right, nil
}
if !isDecimalString(left) {
return "", fmt.Errorf("bad kid in key spec %q", spec)
}
if _, err := mp4.UnpackKey(right); err != nil {
return "", fmt.Errorf("bad key spec %q: %w", spec, err)
}
return right, nil
}
func isDecimalString(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func decryptMP4FFFileWithKeyMap(r, initR io.Reader, w io.Writer, key []byte, keysByKID map[string][]byte, strictKIDMode bool) error {
inMp4, err := mp4.DecodeFile(r)
if err != nil {
return err
}
if !inMp4.IsFragmented() {
return fmt.Errorf("file not fragmented. Not supported")
}
init := inMp4.Init
if inMp4.Init == nil {
if initR == nil {
return fmt.Errorf("no init segment file and no init part of file")
}
initSegment, err := mp4.DecodeFile(initR)
if err != nil {
return fmt.Errorf("could not decode init file: %w", err)
}
init = initSegment.Init
}
decryptInfo, err := mp4.DecryptInit(init)
if err != nil {
return err
}
if inMp4.Init != nil {
if err := inMp4.Init.Encode(w); err != nil {
return err
}
}
for _, segment := range inMp4.Segments {
if inMp4.Init == nil {
if err := segment.ParseSenc(init); err != nil {
return fmt.Errorf("parseSenc: %w", err)
}
}
if err := decryptMP4FFSegmentWithSparseSenc(segment, decryptInfo, key, keysByKID, strictKIDMode); err != nil {
return fmt.Errorf("decryptSegment: %w", err)
}
if err := segment.Encode(w); err != nil {
return err
}
}
return nil
}
func decryptMP4FFSegmentWithSparseSenc(segment *mp4.MediaSegment, decryptInfo mp4.DecryptInfo, key []byte, keysByKID map[string][]byte, strictKIDMode bool) error {
for _, fragment := range segment.Fragments {
if !mp4FragmentContainsSenc(fragment) {
continue
}
if err := mp4.DecryptFragmentWithKeys(fragment, decryptInfo, key, keysByKID, strictKIDMode); err != nil {
return err
}
}
if len(segment.Sidxs) > 0 {
segment.Sidx = nil
segment.Sidxs = nil
}
return nil
}
func mp4FragmentContainsSenc(fragment *mp4.Fragment) bool {
if fragment == nil || fragment.Moof == nil {
return false
}
for _, traf := range fragment.Moof.Trafs {
if traf == nil {
continue
}
hasSenc, _ := traf.ContainsSencBox()
if hasSenc {
return true
}
}
return false
}
+61
View File
@@ -41,6 +41,9 @@ var (
currentSpeed float64
speedLock sync.RWMutex
rateLimitUntilMs int64
rateLimitLock sync.RWMutex
downloadQueue []DownloadItem
downloadQueueLock sync.RWMutex
currentItemID string
@@ -55,6 +58,8 @@ type ProgressInfo struct {
IsDownloading bool `json:"is_downloading"`
MBDownloaded float64 `json:"mb_downloaded"`
SpeedMBps float64 `json:"speed_mbps"`
RateLimited bool `json:"rate_limited"`
RateLimitSecs int `json:"rate_limit_secs"`
}
type DownloadQueueInfo struct {
@@ -82,13 +87,45 @@ func GetDownloadProgress() ProgressInfo {
speed := currentSpeed
speedLock.RUnlock()
rateLimitLock.RLock()
untilMs := rateLimitUntilMs
rateLimitLock.RUnlock()
rateLimited := false
rateLimitSecs := 0
if untilMs > 0 {
remainingMs := untilMs - getCurrentTimeMillis()
if remainingMs > 0 {
rateLimited = true
rateLimitSecs = int((remainingMs + 999) / 1000)
}
}
return ProgressInfo{
IsDownloading: downloading,
MBDownloaded: progress,
SpeedMBps: speed,
RateLimited: rateLimited,
RateLimitSecs: rateLimitSecs,
}
}
func SetRateLimitCooldown(seconds float64) {
rateLimitLock.Lock()
if seconds <= 0 {
rateLimitUntilMs = 0
} else {
rateLimitUntilMs = getCurrentTimeMillis() + int64(seconds*1000)
}
rateLimitLock.Unlock()
}
func ClearRateLimitCooldown() {
rateLimitLock.Lock()
rateLimitUntilMs = 0
rateLimitLock.Unlock()
}
func SetDownloadSpeed(mbps float64) {
speedLock.Lock()
currentSpeed = mbps
@@ -110,6 +147,7 @@ func SetDownloading(downloading bool) {
SetDownloadProgress(0)
SetDownloadSpeed(0)
ClearRateLimitCooldown()
}
}
@@ -147,6 +185,10 @@ func getCurrentTimeMillis() int64 {
}
func (pw *ProgressWriter) Write(p []byte) (int, error) {
if err := CheckDownloadCancelled(); err != nil {
return 0, err
}
n, err := pw.writer.Write(p)
pw.total += int64(n)
@@ -396,6 +438,25 @@ func CancelAllQueuedItems() {
}
}
func CancelQueuedAndDownloadingItems() {
downloadQueueLock.Lock()
for i := range downloadQueue {
if downloadQueue[i].Status == StatusQueued || downloadQueue[i].Status == StatusDownloading {
downloadQueue[i].Status = StatusSkipped
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].ErrorMessage = "Cancelled"
}
}
downloadQueueLock.Unlock()
currentItemLock.Lock()
currentItemID = ""
currentItemLock.Unlock()
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
func ResetSessionIfComplete() {
downloadQueueLock.RLock()
hasActiveOrQueued := false
+47 -7
View File
@@ -22,7 +22,12 @@ import (
)
type QobuzDownloader struct {
client *http.Client
client *http.Client
customURL string
}
func (q *QobuzDownloader) SetCustomAPIURL(apiURL string) {
q.customURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
}
type QobuzTrack struct {
@@ -754,7 +759,33 @@ 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)
if strings.TrimSpace(q.customURL) != "" {
fmt.Printf("Trying custom Qobuz instance...\n")
url, err := q.getQobuzCustomDownloadURL(trackID, qualityCode)
if err == nil {
fmt.Printf("Success (custom Qobuz instance)\n")
return url, nil
}
if IsDownloadCancelledError(err) {
return "", err
}
fmt.Printf("Custom Qobuz instance failed: %v\n", err)
if !allowFallback {
return "", err
}
}
downloadFunc := func(qual string) (string, error) {
if url, err := q.getQobuzCommunityDownloadURL(trackID, qual); err == nil {
fmt.Printf("Success (community qbz-a)\n")
return url, nil
} else if IsDownloadCancelledError(err) {
return "", err
} else {
fmt.Printf("Community qbz-a failed: %v\n", err)
}
attemptMap := make(map[string]qobuzProviderAttempt)
attemptIDs := make([]string, 0, len(GetQobuzDownloadProviderURLs()))
for _, provider := range q.getQobuzDownloadProviders() {
@@ -777,7 +808,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
url, err := attempt.Download()
if err == nil {
fmt.Printf("Success\n")
fmt.Printf("Success\n")
recordProviderSuccess("qobuz", attempt.ID)
return url, nil
}
@@ -793,27 +824,36 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
if err == nil {
return url, nil
}
if IsDownloadCancelledError(err) {
return "", err
}
currentQuality := qualityCode
if currentQuality == "27" && allowFallback {
fmt.Printf("Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n")
fmt.Printf("Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n")
url, err := downloadFunc("7")
if err == nil {
fmt.Println("Success with fallback quality 7")
fmt.Println("Success with fallback quality 7")
return url, nil
}
if IsDownloadCancelledError(err) {
return "", err
}
currentQuality = "7"
}
if currentQuality == "7" && allowFallback {
fmt.Printf("Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n")
fmt.Printf("Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n")
url, err := downloadFunc("6")
if err == nil {
fmt.Println("Success with fallback quality 6")
fmt.Println("Success with fallback quality 6")
return url, nil
}
if IsDownloadCancelledError(err) {
return "", err
}
}
return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err)
@@ -978,7 +1018,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
} else {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
fmt.Println("MusicBrainz metadata fetched")
fmt.Println("MusicBrainz metadata fetched")
metaChan <- fetchedMeta
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
+114
View File
@@ -0,0 +1,114 @@
package backend
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
func mapQobuzQualityToCommunity(quality string) string {
switch strings.TrimSpace(quality) {
case "27", "7":
return "24"
default:
return "16"
}
}
func (q *QobuzDownloader) getQobuzCommunityDownloadURL(trackID int64, quality string) (string, error) {
payload, err := json.Marshal(map[string]string{
"id": fmt.Sprintf("%d", trackID),
"quality": mapQobuzQualityToCommunity(quality),
})
if err != nil {
return "", err
}
resp, err := doCommunityRequest(q.client, "Qobuz", func() (*http.Request, error) {
req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetQobuzCommunityDownloadURL(), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if err := setCommunityRequestHeaders(req); err != nil {
return nil, err
}
return req, nil
})
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("qobuz community API returned status %d", resp.StatusCode)
}
downloadURL := extractQobuzStreamingURL(body)
if downloadURL == "" {
return "", fmt.Errorf("no streamable URL in qobuz community response")
}
return downloadURL, nil
}
func (q *QobuzDownloader) getQobuzCustomDownloadURL(trackID int64, quality string) (string, error) {
base := strings.TrimRight(strings.TrimSpace(q.customURL), "/")
if base == "" {
return "", fmt.Errorf("no custom Qobuz instance configured")
}
qualityCode := strings.TrimSpace(quality)
switch qualityCode {
case "5", "6", "7", "27":
default:
qualityCode = "27"
}
endpoint := fmt.Sprintf("%s/api/download-music?track_id=%d&quality=%s", base, trackID, url.QueryEscape(qualityCode))
req, err := NewRequestWithDefaultHeaders(http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
resp, err := q.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("qobuz custom instance returned status %d", resp.StatusCode)
}
var parsed struct {
Success bool `json:"success"`
Data struct {
URL string `json:"url"`
} `json:"data"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("failed to decode qobuz custom response: %w", err)
}
if !parsed.Success || strings.TrimSpace(parsed.Data.URL) == "" {
if strings.TrimSpace(parsed.Error) != "" {
return "", fmt.Errorf("qobuz custom instance error: %s", parsed.Error)
}
return "", fmt.Errorf("no download URL in qobuz custom response")
}
return strings.TrimSpace(parsed.Data.URL), nil
}
+8 -7
View File
@@ -11,13 +11,14 @@ import (
const recentFetchesFileName = "recent_fetches.json"
type RecentFetchItem struct {
ID string `json:"id"`
URL string `json:"url"`
Type string `json:"type"`
Name string `json:"name"`
Artist string `json:"artist"`
Image string `json:"image"`
Timestamp int64 `json:"timestamp"`
ID string `json:"id"`
URL string `json:"url"`
Type string `json:"type"`
Name string `json:"name"`
Artist string `json:"artist"`
Image string `json:"image"`
IsExplicit bool `json:"is_explicit,omitempty"`
Timestamp int64 `json:"timestamp"`
}
var (
+3 -3
View File
@@ -420,17 +420,17 @@ func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse)
if link, ok := resp.LinksByPlatform["tidal"]; ok && link.URL != "" && links.TidalURL == "" {
links.TidalURL = strings.TrimSpace(link.URL)
fmt.Println("Tidal URL found")
fmt.Println("Tidal URL found")
}
if link, ok := resp.LinksByPlatform["amazonMusic"]; ok && link.URL != "" && links.AmazonURL == "" {
links.AmazonURL = normalizeAmazonMusicURL(link.URL)
fmt.Println("Amazon URL found")
fmt.Println("Amazon URL found")
}
if link, ok := resp.LinksByPlatform["deezer"]; ok && link.URL != "" && links.DeezerURL == "" {
links.DeezerURL = normalizeDeezerTrackURL(link.URL)
fmt.Println("Deezer URL found")
fmt.Println("Deezer URL found")
}
}
+3 -3
View File
@@ -110,19 +110,19 @@ func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) {
case strings.Contains(link, "listen.tidal.com/track"):
if links.TidalURL == "" {
links.TidalURL = link
fmt.Println("Tidal URL found via Songstats")
fmt.Println("Tidal URL found via Songstats")
}
case strings.Contains(link, "music.amazon.com"):
if links.AmazonURL == "" {
if normalized := normalizeAmazonMusicURL(link); normalized != "" {
links.AmazonURL = normalized
fmt.Println("Amazon URL found via Songstats")
fmt.Println("Amazon URL found via Songstats")
}
}
case strings.Contains(link, "deezer.com"):
if links.DeezerURL == "" {
links.DeezerURL = normalizeDeezerTrackURL(link)
fmt.Println("Deezer URL found via Songstats")
fmt.Println("Deezer URL found via Songstats")
}
}
}
+28 -5
View File
@@ -103,6 +103,7 @@ type AlbumInfoMetadata struct {
ReleaseDate string `json:"release_date"`
Artists string `json:"artists"`
Images string `json:"images"`
IsExplicit bool `json:"is_explicit,omitempty"`
UPC string `json:"upc,omitempty"`
Batch string `json:"batch,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
@@ -162,6 +163,7 @@ type DiscographyAlbumMetadata struct {
Artists string `json:"artists"`
Images string `json:"images"`
ExternalURL string `json:"external_urls"`
IsExplicit bool `json:"is_explicit,omitempty"`
}
type ArtistDiscographyPayload struct {
@@ -1104,12 +1106,21 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback
break
}
albumExplicit := false
for _, track := range raw.Tracks {
if track.IsExplicit {
albumExplicit = true
break
}
}
info := AlbumInfoMetadata{
TotalTracks: raw.Count,
Name: raw.Name,
ReleaseDate: raw.ReleaseDate,
Artists: raw.Artists,
Images: raw.Cover,
IsExplicit: albumExplicit,
UPC: raw.UPC,
ArtistID: artistID,
ArtistURL: artistURL,
@@ -1276,8 +1287,10 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
allTracks := make([]AlbumTrackMetadata, 0)
type fetchResult struct {
tracks []AlbumTrackMetadata
err error
albumID string
tracks []AlbumTrackMetadata
isExplicit bool
err error
}
resultsChan := make(chan fetchResult, len(raw.Discography.All))
@@ -1318,7 +1331,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
select {
case <-ctx.Done():
resultsChan <- fetchResult{err: ctx.Err()}
resultsChan <- fetchResult{albumID: albumID, err: ctx.Err()}
return
default:
}
@@ -1326,14 +1339,18 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID, nil)
if err != nil {
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
resultsChan <- fetchResult{albumID: albumID, tracks: []AlbumTrackMetadata{}}
return
}
tracks := make([]AlbumTrackMetadata, 0, len(albumData.Tracks))
albumExplicit := false
for idx, tr := range albumData.Tracks {
durationMS := parseDuration(tr.Duration)
trackNumber := idx + 1
if tr.IsExplicit {
albumExplicit = true
}
var artistID, artistURL string
if len(tr.ArtistIds) > 0 {
@@ -1377,7 +1394,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
if callback != nil {
callback(tracks)
}
resultsChan <- fetchResult{tracks: tracks}
resultsChan <- fetchResult{albumID: albumID, tracks: tracks, isExplicit: albumExplicit}
}(alb.ID, alb.Name)
}
@@ -1386,6 +1403,12 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
if res.err != nil {
return nil, res.err
}
for albumIndex := range albumList {
if albumList[albumIndex].ID == res.albumID {
albumList[albumIndex].IsExplicit = res.isExplicit
break
}
}
allTracks = append(allTracks, res.tracks...)
}
+23 -21
View File
@@ -113,7 +113,7 @@ func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName,
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")
fmt.Println("MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
@@ -253,7 +253,8 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
fmt.Println("Fetching URL...")
if strings.TrimSpace(t.apiURL) == "" {
return "", fmt.Errorf("no configured custom tidal api instance")
fmt.Println("No custom Tidal instance configured, using community tdl-a endpoint")
return t.getTidalCommunityDownloadURL(trackID, quality)
}
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
@@ -261,31 +262,31 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
if err != nil {
fmt.Printf("failed to create request: %v\n", err)
fmt.Printf("failed to create request: %v\n", err)
return "", fmt.Errorf("failed to create request: %w", err)
}
resp, err := t.client.Do(req)
if err != nil {
fmt.Printf("Tidal API request failed: %v\n", err)
fmt.Printf("Tidal API request failed: %v\n", err)
return "", fmt.Errorf("failed to get download URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
fmt.Printf("Tidal API returned status code: %d\n", resp.StatusCode)
fmt.Printf("Tidal API returned status code: %d\n", resp.StatusCode)
return "", fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Failed to read response body: %v\n", err)
fmt.Printf("Failed to read response body: %v\n", err)
return "", fmt.Errorf("failed to read response: %w", err)
}
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
fmt.Println("Tidal manifest found (v2 API)")
fmt.Println("Tidal manifest found (v2 API)")
return "MANIFEST:" + v2Response.Data.Manifest, nil
}
@@ -296,23 +297,23 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
fmt.Printf("Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr)
fmt.Printf("Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr)
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
if len(apiResponses) == 0 {
fmt.Println("Tidal API returned empty response")
fmt.Println("Tidal API returned empty response")
return "", fmt.Errorf("no download URL in response")
}
for _, item := range apiResponses {
if item.OriginalTrackURL != "" {
fmt.Println("Tidal download URL found")
fmt.Println("Tidal download URL found")
return item.OriginalTrackURL, nil
}
}
fmt.Println("No valid download URL in Tidal API response")
fmt.Println("No valid download URL in Tidal API response")
return "", fmt.Errorf("download URL not found in response")
}
@@ -327,7 +328,8 @@ func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) err
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := t.client.Do(req)
downloadClient := &http.Client{Timeout: 5 * time.Minute}
resp, err := downloadClient.Do(req)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
@@ -570,8 +572,11 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
downloadURL, err := t.GetDownloadURL(trackID, quality)
if err != nil {
if IsDownloadCancelledError(err) {
return outputFilename, err
}
if isTidalHiResQuality(quality) && allowFallback {
fmt.Println("HI_RES unavailable/failed, falling back to LOSSLESS...")
fmt.Println("HI_RES unavailable/failed, falling back to LOSSLESS...")
downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS")
if err != nil {
return outputFilename, fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
@@ -590,7 +595,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
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")
fmt.Println("Downloaded successfully from Tidal")
return outputFilename, nil
}
@@ -621,12 +626,12 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
fmt.Printf("Downloaded using API: %s\n", successAPI)
fmt.Printf("Downloaded using API: %s\n", successAPI)
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")
fmt.Println("Downloaded successfully from Tidal")
return outputFilename, nil
}
@@ -637,9 +642,6 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
}
if t.apiURL == "" {
return "", fmt.Errorf("no configured custom tidal api instance")
}
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)
}
@@ -820,7 +822,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename
var lastErr error
for idx, candidateQuality := range qualities {
if idx > 0 {
fmt.Printf("%s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality)
fmt.Printf("%s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality)
}
apiURL, err := t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, candidateQuality, false)
@@ -875,7 +877,7 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena
fmt.Println("All Tidal APIs failed:")
for _, item := range errors {
fmt.Printf(" %s\n", item)
fmt.Printf(" %s\n", item)
}
return "", fmt.Errorf("all tidal apis failed for quality %s: %w", quality, lastErr)
+80
View File
@@ -0,0 +1,80 @@
package backend
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type tidalCommunityResponse struct {
Quality string `json:"quality"`
URL string `json:"url"`
Lyric string `json:"lyric"`
}
var tidalCommunityClient = &http.Client{Timeout: 60 * time.Second}
func mapTidalQualityToCommunity(quality string) string {
switch strings.ToUpper(strings.TrimSpace(quality)) {
case "HI_RES_LOSSLESS", "HI_RES", "24":
return "24"
default:
return "16"
}
}
func (t *TidalDownloader) getTidalCommunityDownloadURL(trackID int64, quality string) (string, error) {
payload, err := json.Marshal(map[string]string{
"id": fmt.Sprintf("%d", trackID),
"quality": mapTidalQualityToCommunity(quality),
})
if err != nil {
return "", err
}
resp, err := doCommunityRequest(tidalCommunityClient, "Tidal", func() (*http.Request, error) {
req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetTidalCommunityDownloadURL(), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if err := setCommunityRequestHeaders(req); err != nil {
return nil, err
}
return req, nil
})
if err != nil {
fmt.Printf("Tidal community request failed: %v\n", err)
return "", fmt.Errorf("failed to get download URL: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
preview := string(body)
if len(preview) > 200 {
preview = preview[:200]
}
fmt.Printf("Tidal community API status %d: %s\n", resp.StatusCode, preview)
return "", fmt.Errorf("tidal community API returned status %d: %s", resp.StatusCode, preview)
}
var parsed tidalCommunityResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("failed to decode tidal community response: %w", err)
}
if strings.TrimSpace(parsed.URL) == "" {
return "", fmt.Errorf("no download URL in tidal community response")
}
fmt.Printf("Tidal community URL found (quality %s)\n", parsed.Quality)
return parsed.URL, nil
}