v7.1.8
This commit is contained in:
+130
-180
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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: "*.*",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user