.clean up the code
This commit is contained in:
@@ -1,537 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
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{
|
||||
Timeout: 120 * time.Second,
|
||||
},
|
||||
regions: []string{"us", "eu"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
fmt.Println("Getting Amazon URL...")
|
||||
client := NewSongLinkClient()
|
||||
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
||||
}
|
||||
|
||||
amazonURL := normalizeAmazonMusicURL(urls.AmazonURL)
|
||||
if amazonURL == "" {
|
||||
return "", fmt.Errorf("amazon Music link not found")
|
||||
}
|
||||
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
|
||||
return amazonURL, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
|
||||
|
||||
asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
|
||||
asin := asinRegex.FindString(amazonURL)
|
||||
if asin == "" {
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var apiResp AmazonStreamResponse
|
||||
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.StreamURL == "" {
|
||||
return "", fmt.Errorf("no stream URL found in response")
|
||||
}
|
||||
|
||||
downloadURL := apiResp.StreamURL
|
||||
fileName := fmt.Sprintf("%s.m4a", asin)
|
||||
filePath := filepath.Join(outputDir, fileName)
|
||||
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dlResp, err := a.client.Do(dlReq)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dlResp.Body.Close()
|
||||
|
||||
fmt.Printf("Downloading track: %s\n", fileName)
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, dlResp.Body)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(filePath)
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
|
||||
if apiResp.DecryptionKey != "" {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
fmt.Println("Decryption successful")
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
|
||||
return a.DownloadFromAfkarXYZ(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) {
|
||||
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
filenameArtist := spotifyArtistName
|
||||
filenameAlbumArtist := spotifyAlbumArtist
|
||||
if useFirstArtistOnly {
|
||||
filenameArtist = GetFirstArtist(spotifyArtistName)
|
||||
filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist)
|
||||
}
|
||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false, isrcOverride)
|
||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
||||
|
||||
if !GetRedownloadWithSuffixSetting() {
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
||||
fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024))
|
||||
return "EXISTS:" + expectedPath, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type mbResult struct {
|
||||
ISRC string
|
||||
Metadata Metadata
|
||||
}
|
||||
|
||||
metaChan := make(chan mbResult, 1)
|
||||
if embedGenre && spotifyURL != "" {
|
||||
go func() {
|
||||
res := mbResult{}
|
||||
var isrc string
|
||||
parts := strings.Split(spotifyURL, "/")
|
||||
if len(parts) > 0 {
|
||||
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
||||
if sID != "" {
|
||||
client := NewSongLinkClient()
|
||||
if val, err := client.GetISRC(sID); err == nil {
|
||||
isrc = val
|
||||
}
|
||||
}
|
||||
}
|
||||
res.ISRC = isrc
|
||||
if isrc != "" {
|
||||
if ShouldSkipMusicBrainzMetadataFetch() {
|
||||
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
|
||||
} else {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||
res.Metadata = fetchedMeta
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
} else {
|
||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
metaChan <- res
|
||||
}()
|
||||
} else {
|
||||
close(metaChan)
|
||||
}
|
||||
|
||||
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
||||
|
||||
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
isrc := strings.TrimSpace(isrcOverride)
|
||||
var mbMeta Metadata
|
||||
if spotifyURL != "" {
|
||||
result := <-metaChan
|
||||
if isrc == "" {
|
||||
isrc = result.ISRC
|
||||
}
|
||||
mbMeta = result.Metadata
|
||||
}
|
||||
|
||||
upc := ""
|
||||
if spotifyURL != "" {
|
||||
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
|
||||
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
|
||||
isrc = strings.TrimSpace(identifiers.ISRC)
|
||||
}
|
||||
upc = strings.TrimSpace(identifiers.UPC)
|
||||
}
|
||||
}
|
||||
|
||||
originalFileDir := filepath.Dir(filePath)
|
||||
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
safeArtist := sanitizeFilename(spotifyArtistName)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
if useFirstArtistOnly {
|
||||
safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName))
|
||||
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||
}
|
||||
|
||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||
safeAlbum := sanitizeFilename(spotifyAlbumName)
|
||||
|
||||
year := ""
|
||||
if len(spotifyReleaseDate) >= 4 {
|
||||
year = spotifyReleaseDate[:4]
|
||||
}
|
||||
|
||||
var newFilename string
|
||||
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
newFilename = filenameFormat
|
||||
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
|
||||
newFilename = strings.ReplaceAll(newFilename, "{isrc}", SanitizeOptionalFilename(isrc))
|
||||
|
||||
if spotifyDiscNumber > 0 {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
||||
} else {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
|
||||
}
|
||||
|
||||
if position > 0 {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
|
||||
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
|
||||
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
|
||||
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
|
||||
}
|
||||
} else {
|
||||
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title":
|
||||
newFilename = safeTitle
|
||||
default:
|
||||
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
if includeTrackNumber && position > 0 {
|
||||
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
|
||||
}
|
||||
}
|
||||
|
||||
ext := filepath.Ext(filePath)
|
||||
if ext == "" {
|
||||
ext = ".flac"
|
||||
}
|
||||
newFilename = newFilename + ext
|
||||
newFilePath := filepath.Join(outputDir, newFilename)
|
||||
if GetRedownloadWithSuffixSetting() {
|
||||
newFilePath, _ = ResolveOutputPathForDownload(newFilePath, true)
|
||||
}
|
||||
|
||||
if err := os.Rename(filePath, newFilePath); err != nil {
|
||||
fmt.Printf("Warning: Failed to rename file: %v\n", err)
|
||||
} else {
|
||||
filePath = newFilePath
|
||||
fmt.Printf("Renamed to: %s\n", newFilename)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Embedding Spotify metadata...")
|
||||
|
||||
coverPath := ""
|
||||
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = filePath + ".cover.jpg"
|
||||
coverClient := NewCoverClient()
|
||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||
coverPath = ""
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Spotify cover downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = 1
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: spotifyTrackName,
|
||||
Artist: spotifyArtistName,
|
||||
Album: spotifyAlbumName,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
TotalTracks: spotifyTotalTracks,
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Comment: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Composer: spotifyComposer,
|
||||
Separator: metadataSeparator,
|
||||
Description: "https://github.com/spotbye/SpotiFLAC",
|
||||
ISRC: isrc,
|
||||
UPC: upc,
|
||||
Genre: mbMeta.Genre,
|
||||
}
|
||||
|
||||
if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil {
|
||||
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Metadata embedded successfully")
|
||||
}
|
||||
|
||||
if strings.HasSuffix(strings.ToLower(filePath), ".flac") {
|
||||
|
||||
originalM4aPath := filepath.Join(originalFileDir, originalFileBase+".m4a")
|
||||
if _, err := os.Stat(originalM4aPath); err == nil {
|
||||
if err := os.Remove(originalM4aPath); err != nil {
|
||||
fmt.Printf("Warning: Failed to remove M4A file: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Cleaned up original M4A file: %s\n", filepath.Base(originalM4aPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Done")
|
||||
fmt.Println("✓ Downloaded successfully from Amazon Music")
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, 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) {
|
||||
|
||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AnalysisResult struct {
|
||||
FilePath string `json:"file_path"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
Channels uint8 `json:"channels"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
TotalSamples uint64 `json:"total_samples"`
|
||||
Duration float64 `json:"duration"`
|
||||
Bitrate int `json:"bit_rate"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
DynamicRange float64 `json:"dynamic_range"`
|
||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||
RMSLevel float64 `json:"rms_level"`
|
||||
}
|
||||
|
||||
type AnalysisDecodeResponse struct {
|
||||
PCMBase64 string `json:"pcm_base64"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
Channels uint8 `json:"channels"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
Duration float64 `json:"duration"`
|
||||
BitrateKbps int `json:"bitrate_kbps,omitempty"`
|
||||
BitDepth string `json:"bit_depth,omitempty"`
|
||||
}
|
||||
|
||||
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
||||
if !fileExists(filepath) {
|
||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||
}
|
||||
|
||||
return GetMetadataWithFFprobe(filepath)
|
||||
}
|
||||
|
||||
func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if f, err := os.Open(filePath); err == nil {
|
||||
f.Close()
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
||||
"-of", "default=noprint_wrappers=0",
|
||||
filePath,
|
||||
}
|
||||
cmd := exec.Command(ffprobePath, args...)
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffprobe failed: %v - %s", err, string(output))
|
||||
}
|
||||
|
||||
infoMap := make(map[string]string)
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "=") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
res := &AnalysisResult{
|
||||
FilePath: filePath,
|
||||
}
|
||||
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
res.FileSize = info.Size()
|
||||
}
|
||||
|
||||
if val, ok := infoMap["sample_rate"]; ok {
|
||||
s, _ := strconv.Atoi(val)
|
||||
res.SampleRate = uint32(s)
|
||||
}
|
||||
if val, ok := infoMap["channels"]; ok {
|
||||
c, _ := strconv.Atoi(val)
|
||||
res.Channels = uint8(c)
|
||||
}
|
||||
if val, ok := infoMap["duration"]; ok {
|
||||
d, _ := strconv.ParseFloat(val, 64)
|
||||
res.Duration = d
|
||||
}
|
||||
if val, ok := infoMap["bit_rate"]; ok && val != "N/A" {
|
||||
br, _ := strconv.Atoi(val)
|
||||
res.Bitrate = br
|
||||
}
|
||||
|
||||
bits := 0
|
||||
if val, ok := infoMap["bits_per_raw_sample"]; ok && val != "N/A" {
|
||||
bits, _ = strconv.Atoi(val)
|
||||
}
|
||||
if bits == 0 {
|
||||
if val, ok := infoMap["bits_per_sample"]; ok && val != "N/A" {
|
||||
bits, _ = strconv.Atoi(val)
|
||||
}
|
||||
}
|
||||
|
||||
res.BitsPerSample = uint8(bits)
|
||||
if bits > 0 {
|
||||
res.BitDepth = fmt.Sprintf("%d-bit", bits)
|
||||
} else {
|
||||
res.BitDepth = "Unknown"
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func DecodeAudioForAnalysis(filePath string) (*AnalysisDecodeResponse, error) {
|
||||
metadata, err := GetTrackMetadata(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pcmBase64, err := extractAnalysisPCMBase64(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &AnalysisDecodeResponse{
|
||||
PCMBase64: pcmBase64,
|
||||
SampleRate: metadata.SampleRate,
|
||||
Channels: metadata.Channels,
|
||||
BitsPerSample: metadata.BitsPerSample,
|
||||
Duration: metadata.Duration,
|
||||
BitDepth: metadata.BitDepth,
|
||||
}
|
||||
|
||||
if metadata.Bitrate > 0 {
|
||||
resp.BitrateKbps = metadata.Bitrate / 1000
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func extractAnalysisPCMBase64(filePath string) (string, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
argSets := [][]string{
|
||||
{
|
||||
"-v", "error",
|
||||
"-i", filePath,
|
||||
"-vn",
|
||||
"-map", "0:a:0",
|
||||
"-af", "pan=mono|c0=c0",
|
||||
"-f", "s16le",
|
||||
"-acodec", "pcm_s16le",
|
||||
"pipe:1",
|
||||
},
|
||||
{
|
||||
"-v", "error",
|
||||
"-i", filePath,
|
||||
"-vn",
|
||||
"-map", "0:a:0",
|
||||
"-ac", "1",
|
||||
"-f", "s16le",
|
||||
"-acodec", "pcm_s16le",
|
||||
"pipe:1",
|
||||
},
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
|
||||
for _, args := range argSets {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
setHideWindow(cmd)
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
lastErr = fmt.Errorf("ffmpeg analysis decode failed: %w - %s", err, strings.TrimSpace(stderr.String()))
|
||||
continue
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
lastErr = fmt.Errorf("ffmpeg analysis decode returned empty PCM output")
|
||||
continue
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(stdout.Bytes()), nil
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ffmpeg analysis decode failed")
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package backend
|
||||
|
||||
import "strings"
|
||||
|
||||
func normalizeArtistSeparator(separator string) string {
|
||||
separator = strings.TrimSpace(separator)
|
||||
if separator == "," || separator == ";" {
|
||||
return separator
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func splitArtistSegment(segment string, separator string) []string {
|
||||
segment = strings.TrimSpace(segment)
|
||||
if segment == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.Contains(segment, "|||SEP|||") {
|
||||
return strings.Split(segment, "|||SEP|||")
|
||||
}
|
||||
|
||||
parts := []string{segment}
|
||||
|
||||
if separator = normalizeArtistSeparator(separator); separator != "" {
|
||||
var separated []string
|
||||
for _, part := range parts {
|
||||
for _, item := range strings.Split(part, separator) {
|
||||
separated = append(separated, item)
|
||||
}
|
||||
}
|
||||
parts = separated
|
||||
} else if strings.Contains(segment, ";") {
|
||||
var separated []string
|
||||
for _, part := range parts {
|
||||
for _, item := range strings.Split(part, ";") {
|
||||
separated = append(separated, item)
|
||||
}
|
||||
}
|
||||
parts = separated
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
func SplitArtistCredits(artistStr, separator string) []string {
|
||||
rawParts := splitArtistSegment(artistStr, separator)
|
||||
if len(rawParts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(rawParts))
|
||||
result := make([]string, 0, len(rawParts))
|
||||
for _, part := range rawParts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[part]; exists {
|
||||
continue
|
||||
}
|
||||
seen[part] = struct{}{}
|
||||
result = append(result, part)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func SplitMetadataValues(value, separator string) []string {
|
||||
rawParts := splitArtistSegment(value, separator)
|
||||
if len(rawParts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(rawParts))
|
||||
result := make([]string, 0, len(rawParts))
|
||||
for _, part := range rawParts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[part]; exists {
|
||||
continue
|
||||
}
|
||||
seen[part] = struct{}{}
|
||||
result = append(result, part)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const legacyTidalAPICacheFile = "tidal-api-urls.json"
|
||||
|
||||
func normalizeCustomTidalAPIValue(value interface{}) string {
|
||||
customAPI, _ := value.(string)
|
||||
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
|
||||
if strings.HasPrefix(customAPI, "https://") {
|
||||
return customAPI
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeDownloaderValue(value interface{}, allowTidal bool) string {
|
||||
downloader, _ := value.(string)
|
||||
switch strings.TrimSpace(strings.ToLower(downloader)) {
|
||||
case "tidal":
|
||||
if allowTidal {
|
||||
return "tidal"
|
||||
}
|
||||
return "auto"
|
||||
case "qobuz":
|
||||
return "qobuz"
|
||||
case "amazon":
|
||||
return "amazon"
|
||||
default:
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeAutoOrderValue(value interface{}, allowTidal bool) string {
|
||||
autoOrder, _ := value.(string)
|
||||
allowed := map[string]struct{}{
|
||||
"qobuz": {},
|
||||
"amazon": {},
|
||||
}
|
||||
fallback := "qobuz-amazon"
|
||||
if allowTidal {
|
||||
allowed["tidal"] = struct{}{}
|
||||
fallback = "tidal-qobuz-amazon"
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
parts := make([]string, 0, 3)
|
||||
for _, rawPart := range strings.Split(strings.TrimSpace(strings.ToLower(autoOrder)), "-") {
|
||||
part := strings.TrimSpace(rawPart)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[part]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[part]; ok {
|
||||
continue
|
||||
}
|
||||
seen[part] = struct{}{}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
|
||||
if len(parts) < 2 {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
func SanitizeSettingsMap(settings map[string]interface{}) map[string]interface{} {
|
||||
if settings == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sanitized := make(map[string]interface{}, len(settings))
|
||||
for key, value := range settings {
|
||||
sanitized[key] = value
|
||||
}
|
||||
|
||||
customAPI := normalizeCustomTidalAPIValue(sanitized["customTidalApi"])
|
||||
sanitized["customTidalApi"] = customAPI
|
||||
allowTidal := customAPI != ""
|
||||
sanitized["downloader"] = sanitizeDownloaderValue(sanitized["downloader"], allowTidal)
|
||||
sanitized["autoOrder"] = sanitizeAutoOrderValue(sanitized["autoOrder"], allowTidal)
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func CleanupLegacyTidalPublicAPIState() error {
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(appDir, legacyTidalAPICacheFile)
|
||||
if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SanitizePersistedConfigSettings() error {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sanitized := SanitizeSettingsMap(settings)
|
||||
payload, err := json.MarshalIndent(sanitized, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath, payload, 0o644)
|
||||
}
|
||||
|
||||
func GetDefaultMusicPath() string {
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
||||
return "C:\\Users\\Public\\Music"
|
||||
}
|
||||
|
||||
return filepath.Join(homeDir, "Music")
|
||||
}
|
||||
|
||||
func GetConfigPath() (string, error) {
|
||||
dir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(dir, "config.json"), nil
|
||||
}
|
||||
|
||||
func LoadConfigSettings() (map[string]interface{}, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return SanitizeSettingsMap(settings), nil
|
||||
}
|
||||
|
||||
func GetRedownloadWithSuffixSetting() bool {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
enabled, _ := settings["redownloadWithSuffix"].(bool)
|
||||
return enabled
|
||||
}
|
||||
|
||||
func GetCustomTidalAPISetting() string {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return normalizeCustomTidalAPIValue(settings["customTidalApi"])
|
||||
}
|
||||
|
||||
func normalizeExistingFileCheckMode(value string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||
case "isrc", "upc":
|
||||
return "isrc"
|
||||
default:
|
||||
return "filename"
|
||||
}
|
||||
}
|
||||
|
||||
func GetExistingFileCheckModeSetting() string {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return "filename"
|
||||
}
|
||||
|
||||
rawMode, _ := settings["existingFileCheckMode"].(string)
|
||||
return normalizeExistingFileCheckMode(rawMode)
|
||||
}
|
||||
|
||||
func GetLinkResolverSetting() string {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return linkResolverProviderDeezerSongLink
|
||||
}
|
||||
|
||||
resolver, _ := settings["linkResolver"].(string)
|
||||
switch strings.TrimSpace(strings.ToLower(resolver)) {
|
||||
case "songlink", linkResolverProviderDeezerSongLink:
|
||||
return linkResolverProviderDeezerSongLink
|
||||
case "songstats":
|
||||
return linkResolverProviderSongstats
|
||||
case "":
|
||||
return linkResolverProviderDeezerSongLink
|
||||
default:
|
||||
return linkResolverProviderDeezerSongLink
|
||||
}
|
||||
}
|
||||
|
||||
func GetLinkResolverAllowFallback() bool {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
allowFallback, ok := settings["allowResolverFallback"].(bool)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return allowFallback
|
||||
}
|
||||
@@ -1,595 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
_ "image/jpeg"
|
||||
)
|
||||
|
||||
const (
|
||||
spotifySize300 = "ab67616d00001e02"
|
||||
spotifySize640 = "ab67616d0000b273"
|
||||
spotifySizeMax = "ab67616d000082c1"
|
||||
)
|
||||
|
||||
type CoverDownloadRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
TrackNumber bool `json:"track_number"`
|
||||
Position int `json:"position"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
}
|
||||
|
||||
type CoverDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
type HeaderDownloadRequest struct {
|
||||
HeaderURL string `json:"header_url"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
}
|
||||
|
||||
type HeaderDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
type CoverClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewCoverClient() *CoverClient {
|
||||
return &CoverClient{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
safeAlbum := sanitizeFilename(albumName)
|
||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
var filename string
|
||||
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title-artist":
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
case "title":
|
||||
filename = safeTitle
|
||||
default:
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d - %s", position, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".jpg"
|
||||
}
|
||||
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
||||
|
||||
mediumURL := convertSmallToMedium(imageURL)
|
||||
if strings.Contains(mediumURL, spotifySize640) {
|
||||
return strings.Replace(mediumURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
return mediumURL
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
downloadURL := convertSmallToMedium(coverURL)
|
||||
if embedMaxQualityCover {
|
||||
downloadURL = c.getMaxResolutionURL(downloadURL)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download cover: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write cover file: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CoverClient) ApplyMacOSFLACFileIcon(filePath, coverURL string, iconSize int, embedMaxQualityCover bool) error {
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("file path is required")
|
||||
}
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "spotiflac-file-icon-*.jpg")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary cover file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if err := c.DownloadCoverToPath(coverURL, tmpPath, embedMaxQualityCover); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SetMacOSFileIconFromImage(filePath, tmpPath, iconSize)
|
||||
}
|
||||
|
||||
func ResizeImageForIcon(sourcePath string, iconSize int) (string, error) {
|
||||
if sourcePath == "" {
|
||||
return "", fmt.Errorf("source image path is required")
|
||||
}
|
||||
if iconSize <= 0 {
|
||||
iconSize = 256
|
||||
}
|
||||
|
||||
in, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open source image: %w", err)
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
srcImage, _, err := image.Decode(in)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode source image: %w", err)
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), srcImage, srcImage.Bounds(), xdraw.Over, nil)
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "spotiflac-resized-icon-*.png")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create resized icon temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer tmpFile.Close()
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, dst); err != nil {
|
||||
return "", fmt.Errorf("failed to encode resized icon image: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(tmpFile, &encoded); err != nil {
|
||||
return "", fmt.Errorf("failed to write resized icon image: %w", err)
|
||||
}
|
||||
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
|
||||
if req.CoverURL == "" {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Cover URL is required",
|
||||
}, fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create output directory: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
filenameFormat := req.FilenameFormat
|
||||
if filenameFormat == "" {
|
||||
filenameFormat = "title-artist"
|
||||
}
|
||||
filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
|
||||
filePath := filepath.Join(outputDir, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &CoverDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Cover file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
downloadURL := c.getMaxResolutionURL(req.CoverURL)
|
||||
|
||||
resp, err := c.httpClient.Get(downloadURL)
|
||||
if err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download cover: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download cover: HTTP %d", resp.StatusCode),
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write cover file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &CoverDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Cover downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadHeader(req HeaderDownloadRequest) (*HeaderDownloadResponse, error) {
|
||||
if req.HeaderURL == "" {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Header URL is required",
|
||||
}, fmt.Errorf("header URL is required")
|
||||
}
|
||||
|
||||
if req.ArtistName == "" {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Artist name is required",
|
||||
}, fmt.Errorf("artist name is required")
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
|
||||
if err := os.MkdirAll(artistFolder, 0755); err != nil {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create artist folder: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(req.ArtistName) + "_Header.jpg"
|
||||
filePath := filepath.Join(artistFolder, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Header file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(req.HeaderURL)
|
||||
if err != nil {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download header: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download header: HTTP %d", resp.StatusCode),
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write header file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &HeaderDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Header downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type GalleryImageDownloadRequest struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
ImageIndex int `json:"image_index"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
}
|
||||
|
||||
type GalleryImageDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadGalleryImage(req GalleryImageDownloadRequest) (*GalleryImageDownloadResponse, error) {
|
||||
if req.ImageURL == "" {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Image URL is required",
|
||||
}, fmt.Errorf("image URL is required")
|
||||
}
|
||||
|
||||
if req.ArtistName == "" {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Artist name is required",
|
||||
}, fmt.Errorf("artist name is required")
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
|
||||
if err := os.MkdirAll(artistFolder, 0755); err != nil {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create artist folder: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(req.ArtistName) + fmt.Sprintf("_Gallery_%d.jpg", req.ImageIndex+1)
|
||||
filePath := filepath.Join(artistFolder, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Gallery image file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(req.ImageURL)
|
||||
if err != nil {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download gallery image: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download gallery image: HTTP %d", resp.StatusCode),
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write gallery image file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Gallery image downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type AvatarDownloadRequest struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
}
|
||||
|
||||
type AvatarDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadAvatar(req AvatarDownloadRequest) (*AvatarDownloadResponse, error) {
|
||||
if req.AvatarURL == "" {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Avatar URL is required",
|
||||
}, fmt.Errorf("avatar URL is required")
|
||||
}
|
||||
|
||||
if req.ArtistName == "" {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Artist name is required",
|
||||
}, fmt.Errorf("artist name is required")
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
|
||||
if err := os.MkdirAll(artistFolder, 0755); err != nil {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create artist folder: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(req.ArtistName) + "_Avatar.jpg"
|
||||
filePath := filepath.Join(artistFolder, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Avatar file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(req.AvatarURL)
|
||||
if err != nil {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download avatar: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download avatar: HTTP %d", resp.StatusCode),
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write avatar file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &AvatarDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Avatar downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
previewMaxSeconds = 35
|
||||
previewExpectedMinSeconds = 60
|
||||
largeMismatchMinExpected = 90
|
||||
minAllowedDurationDiff = 15
|
||||
durationDiffRatio = 0.25
|
||||
)
|
||||
|
||||
func ValidateDownloadedTrackDuration(filePath string, expectedSeconds int) (bool, error) {
|
||||
if filePath == "" || expectedSeconds <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
actualDuration, err := GetAudioDuration(filePath)
|
||||
if err != nil || actualDuration <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
actualSeconds := int(math.Round(actualDuration))
|
||||
if actualSeconds <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if expectedSeconds >= previewExpectedMinSeconds && actualSeconds <= previewMaxSeconds {
|
||||
return true, fmt.Errorf("detected preview/sample download: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds)
|
||||
}
|
||||
|
||||
if expectedSeconds >= largeMismatchMinExpected {
|
||||
allowedDiff := int(math.Max(minAllowedDurationDiff, math.Round(float64(expectedSeconds)*durationDiffRatio)))
|
||||
diff := int(math.Abs(float64(actualSeconds - expectedSeconds)))
|
||||
if diff > allowedDiff {
|
||||
return true, fmt.Errorf("downloaded file duration mismatch: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,947 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type executableCandidate struct {
|
||||
path string
|
||||
source string
|
||||
}
|
||||
|
||||
func ValidateExecutable(path string) error {
|
||||
cleanedPath := filepath.Clean(path)
|
||||
if cleanedPath == "" {
|
||||
return fmt.Errorf("empty path")
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(cleanedPath) {
|
||||
return fmt.Errorf("path must be absolute: %s", path)
|
||||
}
|
||||
|
||||
info, err := os.Stat(cleanedPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("path is a directory: %s", path)
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
if info.Mode()&0111 == 0 {
|
||||
return fmt.Errorf("file is not executable: %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
base := filepath.Base(cleanedPath)
|
||||
validNames := map[string]bool{
|
||||
"ffmpeg": true,
|
||||
"ffmpeg.exe": true,
|
||||
"ffprobe": true,
|
||||
"ffprobe.exe": true,
|
||||
}
|
||||
if !validNames[base] {
|
||||
return fmt.Errorf("invalid executable name: %s", base)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetAppDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, ".spotiflac"), nil
|
||||
}
|
||||
|
||||
func EnsureAppDir() (string, error) {
|
||||
appDir, err := GetAppDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(appDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("failed to create app directory: %w", err)
|
||||
}
|
||||
|
||||
return appDir, nil
|
||||
}
|
||||
|
||||
func GetFFmpegDir() (string, error) {
|
||||
return EnsureAppDir()
|
||||
}
|
||||
|
||||
func copyExecutable(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := out.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return prepareExecutableForUse(dst)
|
||||
}
|
||||
|
||||
func appendExecutableCandidate(candidates []executableCandidate, seen map[string]struct{}, path, source string) []executableCandidate {
|
||||
cleanedPath := filepath.Clean(strings.TrimSpace(path))
|
||||
if cleanedPath == "" {
|
||||
return candidates
|
||||
}
|
||||
if _, exists := seen[cleanedPath]; exists {
|
||||
return candidates
|
||||
}
|
||||
|
||||
seen[cleanedPath] = struct{}{}
|
||||
return append(candidates, executableCandidate{
|
||||
path: cleanedPath,
|
||||
source: source,
|
||||
})
|
||||
}
|
||||
|
||||
func resolveSystemExecutable(executableName string) string {
|
||||
if runtime.GOOS == "darwin" {
|
||||
candidates := []string{
|
||||
"/opt/homebrew/bin/" + executableName,
|
||||
"/usr/local/bin/" + executableName,
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
path, err := exec.Command("which", executableName).Output()
|
||||
if err == nil {
|
||||
trimmed := strings.TrimSpace(string(path))
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(executableName)
|
||||
if err == nil {
|
||||
return path
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func runExecutableVersionCheck(path string) error {
|
||||
cmd := exec.Command(path, "-version")
|
||||
setHideWindow(cmd)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func removeMacOSQuarantineAttribute(path string) error {
|
||||
cmd := exec.Command("xattr", "-d", "com.apple.quarantine", path)
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
trimmedOutput := strings.TrimSpace(string(output))
|
||||
lowerOutput := strings.ToLower(trimmedOutput)
|
||||
if strings.Contains(lowerOutput, "no such xattr") || strings.Contains(lowerOutput, "attribute not found") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if trimmedOutput != "" {
|
||||
return fmt.Errorf("%w: %s", err, trimmedOutput)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func prepareExecutableForUse(path string) error {
|
||||
cleanedPath := filepath.Clean(strings.TrimSpace(path))
|
||||
if cleanedPath == "" {
|
||||
return fmt.Errorf("empty path")
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.Chmod(cleanedPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to mark executable: %w", err)
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
if err := removeMacOSQuarantineAttribute(cleanedPath); err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: failed to remove macOS quarantine from %s: %v\n", cleanedPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveExecutablePath(executableName string) (string, string, error) {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
localPath := filepath.Join(ffmpegDir, executableName)
|
||||
nextDir := filepath.Join(filepath.Dir(ffmpegDir), ".spotiflac-next")
|
||||
nextPath := filepath.Join(nextDir, executableName)
|
||||
localExists := false
|
||||
candidates := make([]executableCandidate, 0, 3)
|
||||
seen := make(map[string]struct{}, 3)
|
||||
|
||||
if systemPath := resolveSystemExecutable(executableName); systemPath != "" {
|
||||
candidates = appendExecutableCandidate(candidates, seen, systemPath, "system")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
localExists = true
|
||||
candidates = appendExecutableCandidate(candidates, seen, localPath, "local")
|
||||
}
|
||||
|
||||
if !localExists {
|
||||
if _, err := os.Stat(nextPath); err == nil {
|
||||
if copyErr := copyExecutable(nextPath, localPath); copyErr == nil {
|
||||
fmt.Printf("[FFmpeg] Copied %s from SpotiFLAC-Next folder\n", executableName)
|
||||
candidates = appendExecutableCandidate(candidates, seen, localPath, "migrated")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, candidate := range candidates {
|
||||
if candidate.source != "system" {
|
||||
if err := prepareExecutableForUse(candidate.path); err != nil {
|
||||
lastErr = err
|
||||
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(candidate.path); err != nil {
|
||||
lastErr = err
|
||||
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := runExecutableVersionCheck(candidate.path); err != nil {
|
||||
lastErr = err
|
||||
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
return candidate.path, localPath, nil
|
||||
}
|
||||
|
||||
if len(candidates) > 0 {
|
||||
if lastErr != nil {
|
||||
return "", localPath, fmt.Errorf("no working %s executable found: %w", executableName, lastErr)
|
||||
}
|
||||
return "", localPath, fmt.Errorf("no working %s executable found", executableName)
|
||||
}
|
||||
|
||||
return "", localPath, fmt.Errorf("%s not found in app directory or system path", executableName)
|
||||
}
|
||||
|
||||
func GetFFmpegPath() (string, error) {
|
||||
ffmpegName := "ffmpeg"
|
||||
if runtime.GOOS == "windows" {
|
||||
ffmpegName = "ffmpeg.exe"
|
||||
}
|
||||
|
||||
path, localPath, err := resolveExecutablePath(ffmpegName)
|
||||
if err != nil {
|
||||
if localPath != "" {
|
||||
return localPath, err
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func GetFFprobePath() (string, error) {
|
||||
ffprobeName := "ffprobe"
|
||||
if runtime.GOOS == "windows" {
|
||||
ffprobeName = "ffprobe.exe"
|
||||
}
|
||||
|
||||
path, localPath, err := resolveExecutablePath(ffprobeName)
|
||||
if err != nil {
|
||||
if localPath != "" {
|
||||
return localPath, err
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func IsFFprobeInstalled() (bool, error) {
|
||||
_, err := GetFFprobePath()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
func IsFFmpegInstalled() (bool, error) {
|
||||
if _, err := GetFFmpegPath(); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return IsFFprobeInstalled()
|
||||
}
|
||||
|
||||
func GetBrewPath() string {
|
||||
brewPaths := []string{
|
||||
"/opt/homebrew/bin/brew",
|
||||
"/usr/local/bin/brew",
|
||||
}
|
||||
|
||||
for _, path := range brewPaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func IsBrewFFmpegInstalled() (bool, error) {
|
||||
brewPath := GetBrewPath()
|
||||
if brewPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cmd := exec.Command(brewPath, "list", "ffmpeg")
|
||||
setHideWindow(cmd)
|
||||
err := cmd.Run()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
func InstallFFmpegWithBrew(progressCallback func(int, string)) error {
|
||||
brewPath := GetBrewPath()
|
||||
if brewPath == "" {
|
||||
return fmt.Errorf("brew not found")
|
||||
}
|
||||
|
||||
progressCallback(10, "Installing FFmpeg via Homebrew...")
|
||||
|
||||
cmd := exec.Command(brewPath, "install", "ffmpeg")
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to install ffmpeg: %w - %s", err, string(output))
|
||||
}
|
||||
|
||||
progressCallback(100, "done")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const ffmpegReleaseBaseURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.1"
|
||||
|
||||
func buildFFmpegReleaseURL(assetName string) string {
|
||||
return ffmpegReleaseBaseURL + "/" + assetName
|
||||
}
|
||||
|
||||
func getFFmpegDownloadURLs() ([]string, []string, error) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return []string{buildFFmpegReleaseURL("ffmpeg-windows.zip")}, []string{buildFFmpegReleaseURL("ffprobe-windows.zip")}, nil
|
||||
case "linux":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return []string{buildFFmpegReleaseURL("ffmpeg-linux-amd64.zip")}, []string{buildFFmpegReleaseURL("ffprobe-linux-amd64.zip")}, nil
|
||||
case "arm64":
|
||||
return []string{buildFFmpegReleaseURL("ffmpeg-linux-arm64v8.zip")}, []string{buildFFmpegReleaseURL("ffprobe-linux-arm64v8.zip")}, nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported Linux architecture: %s", runtime.GOARCH)
|
||||
}
|
||||
case "darwin":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return []string{buildFFmpegReleaseURL("ffmpeg-macos-amd64.zip")}, []string{buildFFmpegReleaseURL("ffprobe-macos-amd64.zip")}, nil
|
||||
case "arm64":
|
||||
return []string{buildFFmpegReleaseURL("ffmpeg-macos-arm64.zip")}, []string{buildFFmpegReleaseURL("ffprobe-macos-arm64.zip")}, nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported macOS architecture: %s", runtime.GOARCH)
|
||||
}
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
|
||||
SetDownloadProgress(0)
|
||||
SetDownloadSpeed(0)
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
|
||||
}
|
||||
|
||||
ffmpegInstalled, _ := IsFFmpegInstalled()
|
||||
ffprobeInstalled, _ := IsFFprobeInstalled()
|
||||
|
||||
ffmpegURLs, ffprobeURLs, err := getFFmpegDownloadURLs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !ffmpegInstalled && !ffprobeInstalled {
|
||||
if err := downloadWithFallback(ffmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := downloadWithFallback(ffprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !ffmpegInstalled {
|
||||
return downloadWithFallback(ffmpegURLs, ffmpegDir, progressCallback, 0, 100)
|
||||
}
|
||||
|
||||
if !ffprobeInstalled {
|
||||
return downloadWithFallback(ffprobeURLs, ffmpegDir, progressCallback, 0, 100)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadWithFallback(urls []string, destDir string, progressCallback func(int), start, end int) error {
|
||||
var lastErr error
|
||||
for _, url := range urls {
|
||||
fmt.Printf("[FFmpeg] Trying to download from: %s\n", url)
|
||||
err := downloadAndExtract(url, destDir, progressCallback, start, end)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
fmt.Printf("[FFmpeg] Attempt failed: %v\n", err)
|
||||
}
|
||||
return fmt.Errorf("all download attempts failed: %w", lastErr)
|
||||
}
|
||||
|
||||
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to download: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
totalSize := resp.ContentLength
|
||||
var downloaded int64
|
||||
lastTime := time.Now()
|
||||
var lastBytes int64
|
||||
|
||||
if totalSize > 0 {
|
||||
totalSizeMB := float64(totalSize) / (1024 * 1024)
|
||||
fmt.Printf("[FFmpeg] Total size: %.2f MB\n", totalSizeMB)
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] Downloading... (size unknown)\n")
|
||||
}
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
_, writeErr := tmpFile.Write(buf[:n])
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("failed to write to temp file: %w", writeErr)
|
||||
}
|
||||
downloaded += int64(n)
|
||||
|
||||
mbDownloaded := float64(downloaded) / (1024 * 1024)
|
||||
now := time.Now()
|
||||
timeDiff := now.Sub(lastTime).Seconds()
|
||||
var speedMBps float64
|
||||
|
||||
if timeDiff > 0.1 {
|
||||
bytesDiff := float64(downloaded - lastBytes)
|
||||
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||
lastTime = now
|
||||
lastBytes = downloaded
|
||||
}
|
||||
|
||||
SetDownloadProgress(mbDownloaded)
|
||||
if speedMBps > 0 {
|
||||
SetDownloadSpeed(speedMBps)
|
||||
}
|
||||
|
||||
if totalSize > 0 && progressCallback != nil {
|
||||
rawProgress := float64(downloaded) / float64(totalSize)
|
||||
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
|
||||
progressCallback(scaledProgress)
|
||||
}
|
||||
|
||||
if totalSize > 0 {
|
||||
percent := float64(downloaded) * 100 / float64(totalSize)
|
||||
if speedMBps > 0 {
|
||||
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%) - %.2f MB/s",
|
||||
mbDownloaded, float64(totalSize)/(1024*1024), percent, speedMBps)
|
||||
} else {
|
||||
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%)",
|
||||
mbDownloaded, float64(totalSize)/(1024*1024), percent)
|
||||
}
|
||||
} else {
|
||||
if speedMBps > 0 {
|
||||
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB - %.2f MB/s", mbDownloaded, speedMBps)
|
||||
} else {
|
||||
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB", mbDownloaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tmpFile.Close()
|
||||
|
||||
if totalSize > 0 {
|
||||
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB / %.2f MB (100%%) \n",
|
||||
float64(downloaded)/(1024*1024), float64(totalSize)/(1024*1024))
|
||||
} else {
|
||||
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB \n", float64(downloaded)/(1024*1024))
|
||||
}
|
||||
fmt.Printf("[FFmpeg] Extracting...\n")
|
||||
|
||||
if strings.HasSuffix(url, ".tar.xz") {
|
||||
return extractTarXz(tmpFile.Name(), destDir)
|
||||
}
|
||||
if strings.HasSuffix(url, ".zip") {
|
||||
return extractZip(tmpFile.Name(), destDir)
|
||||
}
|
||||
return fmt.Errorf("unsupported archive format for %s", url)
|
||||
}
|
||||
|
||||
func extractZip(zipPath, destDir string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open zip: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
ffmpegName := "ffmpeg"
|
||||
ffprobeName := "ffprobe"
|
||||
if runtime.GOOS == "windows" {
|
||||
ffmpegName = "ffmpeg.exe"
|
||||
ffprobeName = "ffprobe.exe"
|
||||
}
|
||||
|
||||
foundFFmpeg := false
|
||||
foundFFprobe := false
|
||||
|
||||
for _, f := range r.File {
|
||||
baseName := filepath.Base(f.Name)
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
var destPath string
|
||||
if baseName == ffmpegName {
|
||||
destPath = filepath.Join(destDir, ffmpegName)
|
||||
foundFFmpeg = true
|
||||
} else if baseName == ffprobeName {
|
||||
destPath = filepath.Join(destDir, ffprobeName)
|
||||
foundFFprobe = true
|
||||
} else {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Found: %s\n", f.Name)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file in zip: %w", err)
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
rc.Close()
|
||||
outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
|
||||
if err := prepareExecutableForUse(destPath); err != nil {
|
||||
return fmt.Errorf("failed to prepare extracted executable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
}
|
||||
|
||||
if !foundFFmpeg && !foundFFprobe {
|
||||
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
|
||||
}
|
||||
|
||||
if foundFFmpeg {
|
||||
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
|
||||
}
|
||||
if foundFFprobe {
|
||||
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTarXz(tarXzPath, destDir string) error {
|
||||
file, err := os.Open(tarXzPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open tar.xz: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
xzReader, err := xz.NewReader(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create xz reader: %w", err)
|
||||
}
|
||||
|
||||
tarReader := tar.NewReader(xzReader)
|
||||
|
||||
ffmpegName := "ffmpeg"
|
||||
ffprobeName := "ffprobe"
|
||||
foundFFmpeg := false
|
||||
foundFFprobe := false
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read tar: %w", err)
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
|
||||
baseName := filepath.Base(header.Name)
|
||||
var destPath string
|
||||
|
||||
if baseName == ffmpegName {
|
||||
destPath = filepath.Join(destDir, ffmpegName)
|
||||
foundFFmpeg = true
|
||||
} else if baseName == ffprobeName {
|
||||
destPath = filepath.Join(destDir, ffprobeName)
|
||||
foundFFprobe = true
|
||||
} else {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Found: %s\n", header.Name)
|
||||
|
||||
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, tarReader)
|
||||
outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
|
||||
if err := prepareExecutableForUse(destPath); err != nil {
|
||||
return fmt.Errorf("failed to prepare extracted executable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
}
|
||||
|
||||
if !foundFFmpeg && !foundFFprobe {
|
||||
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
|
||||
}
|
||||
|
||||
if foundFFmpeg {
|
||||
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
|
||||
}
|
||||
if foundFFprobe {
|
||||
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ConvertAudioRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
OutputFormat string `json:"output_format"`
|
||||
Bitrate string `json:"bitrate"`
|
||||
Codec string `json:"codec"`
|
||||
}
|
||||
|
||||
type ConvertAudioResult struct {
|
||||
InputFile string `json:"input_file"`
|
||||
OutputFile string `json:"output_file"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||
return nil, fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
installed, err := IsFFmpegInstalled()
|
||||
if err != nil || !installed {
|
||||
return nil, fmt.Errorf("ffmpeg is not installed")
|
||||
}
|
||||
|
||||
results := make([]ConvertAudioResult, len(req.InputFiles))
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for i, inputFile := range req.InputFiles {
|
||||
wg.Add(1)
|
||||
go func(idx int, inputFile string) {
|
||||
defer wg.Done()
|
||||
|
||||
result := ConvertAudioResult{
|
||||
InputFile: inputFile,
|
||||
}
|
||||
|
||||
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
||||
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
||||
inputDir := filepath.Dir(inputFile)
|
||||
|
||||
outputFormatUpper := strings.ToUpper(req.OutputFormat)
|
||||
outputDir := filepath.Join(inputDir, outputFormatUpper)
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
outputExt := "." + strings.ToLower(req.OutputFormat)
|
||||
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
||||
outputFile = norm.NFC.String(outputFile)
|
||||
|
||||
if inputExt == outputExt {
|
||||
result.Error = "Input and output formats are the same"
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
result.OutputFile = outputFile
|
||||
|
||||
var coverArtPath string
|
||||
var lyrics string
|
||||
var inputMetadata Metadata
|
||||
|
||||
inputMetadata, err = ExtractFullMetadataFromFile(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
|
||||
}
|
||||
|
||||
inputFile = norm.NFC.String(inputFile)
|
||||
coverArtPath, err = ExtractCoverArt(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract cover art from %s: %v\n", inputFile, err)
|
||||
}
|
||||
lyrics, err = ExtractLyrics(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
|
||||
} else if lyrics != "" {
|
||||
fmt.Printf("[FFmpeg] Lyrics extracted from %s: %d characters\n", inputFile, len(lyrics))
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
|
||||
}
|
||||
|
||||
inputMetadata.Lyrics = lyrics
|
||||
|
||||
args := []string{
|
||||
"-i", inputFile,
|
||||
"-y",
|
||||
}
|
||||
|
||||
switch req.OutputFormat {
|
||||
case "mp3":
|
||||
args = append(args,
|
||||
"-codec:a", "libmp3lame",
|
||||
"-b:a", req.Bitrate,
|
||||
"-map", "0:a",
|
||||
"-id3v2_version", "3",
|
||||
)
|
||||
case "m4a":
|
||||
|
||||
codec := req.Codec
|
||||
if codec == "" {
|
||||
codec = "aac"
|
||||
}
|
||||
|
||||
if codec == "alac" {
|
||||
|
||||
args = append(args,
|
||||
"-codec:a", "alac",
|
||||
"-map", "0:a",
|
||||
)
|
||||
} else {
|
||||
|
||||
args = append(args,
|
||||
"-codec:a", "aac",
|
||||
"-b:a", req.Bitrate,
|
||||
"-map", "0:a",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, outputFile)
|
||||
|
||||
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("conversion failed: %s - %s", err.Error(), string(output))
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
|
||||
if coverArtPath != "" {
|
||||
os.Remove(coverArtPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := EmbedMetadataToConvertedFile(outputFile, inputMetadata, coverArtPath); err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to embed metadata: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] Metadata embedded successfully\n")
|
||||
}
|
||||
|
||||
if lyrics != "" {
|
||||
if err := EmbedLyricsOnlyUniversal(outputFile, lyrics); err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to embed lyrics: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] Lyrics embedded successfully\n")
|
||||
}
|
||||
}
|
||||
|
||||
if coverArtPath != "" {
|
||||
os.Remove(coverArtPath)
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
|
||||
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
}(i, inputFile)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results, nil
|
||||
}
|
||||
|
||||
type AudioFileInfo struct {
|
||||
Path string `json:"path"`
|
||||
Filename string `json:"filename"`
|
||||
Format string `json:"format"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), "."))
|
||||
return &AudioFileInfo{
|
||||
Path: filePath,
|
||||
Filename: filepath.Base(filePath),
|
||||
Format: ext,
|
||||
Size: info.Size(),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func setHideWindow(cmd *exec.Cmd) {
|
||||
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func setHideWindow(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
func SelectMultipleFiles(ctx context.Context) ([]string, error) {
|
||||
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select Audio Files",
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac, *.aac)",
|
||||
Pattern: "*.mp3;*.m4a;*.flac;*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "MP3 Files (*.mp3)",
|
||||
Pattern: "*.mp3",
|
||||
},
|
||||
{
|
||||
DisplayName: "M4A Files (*.m4a)",
|
||||
Pattern: "*.m4a",
|
||||
},
|
||||
{
|
||||
DisplayName: "FLAC Files (*.flac)",
|
||||
Pattern: "*.flac",
|
||||
},
|
||||
{
|
||||
DisplayName: "AAC Files (*.aac)",
|
||||
Pattern: "*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func SelectOutputDirectory(ctx context.Context) (string, error) {
|
||||
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select Output Directory",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("file path is required")
|
||||
}
|
||||
if imagePath == "" {
|
||||
return fmt.Errorf("image path is required")
|
||||
}
|
||||
|
||||
resizedPath, err := ResizeImageForIcon(imagePath, iconSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(resizedPath)
|
||||
|
||||
script := `
|
||||
use framework "AppKit"
|
||||
on run argv
|
||||
set imagePath to item 1 of argv
|
||||
set targetPath to item 2 of argv
|
||||
set iconImage to current application's NSImage's alloc()'s initWithContentsOfFile:imagePath
|
||||
if iconImage is missing value then error "Failed to load icon image"
|
||||
set didSet to (current application's NSWorkspace's sharedWorkspace()'s setIcon:iconImage forFile:targetPath options:0) as boolean
|
||||
if didSet is false then error "Failed to set custom file icon"
|
||||
end run
|
||||
`
|
||||
|
||||
cmd := exec.Command("osascript", "-", resizedPath, filePath)
|
||||
cmd.Stdin = strings.NewReader(script)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply macOS file icon: %v (%s)", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build !darwin
|
||||
|
||||
package backend
|
||||
|
||||
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,503 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
id3v2 "github.com/bogem/id3v2/v2"
|
||||
"github.com/go-flac/flacvorbis"
|
||||
"github.com/go-flac/go-flac"
|
||||
)
|
||||
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
Children []FileInfo `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type AudioMetadata struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
Year string `json:"year"`
|
||||
ISRC string `json:"isrc"`
|
||||
UPC string `json:"upc"`
|
||||
}
|
||||
|
||||
type RenamePreview struct {
|
||||
OldPath string `json:"old_path"`
|
||||
OldName string `json:"old_name"`
|
||||
NewName string `json:"new_name"`
|
||||
NewPath string `json:"new_path"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Metadata AudioMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
type RenameResult struct {
|
||||
OldPath string `json:"old_path"`
|
||||
NewPath string `json:"new_path"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func ListDirectory(dirPath string) ([]FileInfo, error) {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
var result []FileInfo
|
||||
for _, entry := range entries {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fileInfo := FileInfo{
|
||||
Name: entry.Name(),
|
||||
Path: filepath.Join(dirPath, entry.Name()),
|
||||
IsDir: entry.IsDir(),
|
||||
Size: info.Size(),
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
children, err := ListDirectory(fileInfo.Path)
|
||||
if err == nil {
|
||||
fileInfo.Children = children
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, fileInfo)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ListAudioFiles(dirPath string) ([]FileInfo, error) {
|
||||
var result []FileInfo
|
||||
|
||||
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" || ext == ".aac" {
|
||||
result = append(result, FileInfo{
|
||||
Name: info.Name(),
|
||||
Path: path,
|
||||
IsDir: false,
|
||||
Size: info.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to walk directory: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
|
||||
if !fileExists(filePath) {
|
||||
return nil, fmt.Errorf("file does not exist")
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return readFlacMetadata(filePath)
|
||||
case ".mp3":
|
||||
return readMp3Metadata(filePath)
|
||||
case ".m4a":
|
||||
return readM4aMetadata(filePath)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func readFlacMetadata(filePath string) (*AudioMetadata, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
|
||||
for _, block := range f.Meta {
|
||||
if block.Type == flac.VorbisComment {
|
||||
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])
|
||||
value := parts[1]
|
||||
|
||||
switch fieldName {
|
||||
case "TITLE":
|
||||
metadata.Title = value
|
||||
case "ARTIST":
|
||||
metadata.Artist = value
|
||||
case "ALBUM":
|
||||
metadata.Album = value
|
||||
case "ALBUMARTIST":
|
||||
metadata.AlbumArtist = value
|
||||
case "TRACKNUMBER":
|
||||
if num, err := strconv.Atoi(value); err == nil {
|
||||
metadata.TrackNumber = num
|
||||
}
|
||||
case "DISCNUMBER":
|
||||
if num, err := strconv.Atoi(value); err == nil {
|
||||
metadata.DiscNumber = num
|
||||
}
|
||||
case "DATE", "YEAR":
|
||||
metadata.Year = value
|
||||
case "ISRC", "TSRC":
|
||||
metadata.ISRC = value
|
||||
case "UPC":
|
||||
assignPreferredUPC(&metadata.UPC, value, true)
|
||||
case "BARCODE":
|
||||
assignPreferredUPC(&metadata.UPC, value, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
||||
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open MP3 file: %w", err)
|
||||
}
|
||||
defer tag.Close()
|
||||
|
||||
metadata := &AudioMetadata{
|
||||
Title: tag.Title(),
|
||||
Artist: tag.Artist(),
|
||||
Album: tag.Album(),
|
||||
Year: tag.Year(),
|
||||
}
|
||||
|
||||
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
|
||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
metadata.AlbumArtist = textFrame.Text
|
||||
}
|
||||
}
|
||||
|
||||
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
|
||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
trackStr := strings.Split(textFrame.Text, "/")[0]
|
||||
if num, err := strconv.Atoi(trackStr); err == nil {
|
||||
metadata.TrackNumber = num
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
|
||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
discStr := strings.Split(textFrame.Text, "/")[0]
|
||||
if num, err := strconv.Atoi(discStr); err == nil {
|
||||
metadata.DiscNumber = num
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if frames := tag.GetFrames("TSRC"); len(frames) > 0 {
|
||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
metadata.ISRC = textFrame.Text
|
||||
}
|
||||
}
|
||||
if frames := tag.GetFrames("TXXX"); len(frames) > 0 {
|
||||
for _, frame := range frames {
|
||||
userTextFrame, ok := frame.(id3v2.UserDefinedTextFrame)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
matched, preferred := classifyUPCDescription(userTextFrame.Description)
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
assignPreferredUPC(&metadata.UPC, userTextFrame.Value, preferred)
|
||||
if preferred && strings.TrimSpace(metadata.UPC) != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffprobePath); err != nil {
|
||||
return nil, 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 nil, err
|
||||
}
|
||||
|
||||
var result 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, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
|
||||
allTags := make(map[string]string)
|
||||
|
||||
for _, stream := range result.Streams {
|
||||
for key, value := range stream.Tags {
|
||||
allTags[strings.ToLower(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range result.Format.Tags {
|
||||
allTags[strings.ToLower(key)] = value
|
||||
}
|
||||
|
||||
for key, value := range allTags {
|
||||
switch key {
|
||||
case "title":
|
||||
metadata.Title = value
|
||||
case "artist":
|
||||
metadata.Artist = value
|
||||
case "album":
|
||||
metadata.Album = value
|
||||
case "album_artist", "albumartist":
|
||||
metadata.AlbumArtist = value
|
||||
case "track":
|
||||
|
||||
trackStr := strings.Split(value, "/")[0]
|
||||
if num, err := strconv.Atoi(trackStr); err == nil {
|
||||
metadata.TrackNumber = num
|
||||
}
|
||||
case "disc":
|
||||
discStr := strings.Split(value, "/")[0]
|
||||
if num, err := strconv.Atoi(discStr); err == nil {
|
||||
metadata.DiscNumber = num
|
||||
}
|
||||
case "date", "year":
|
||||
if metadata.Year == "" || len(value) > len(metadata.Year) {
|
||||
metadata.Year = value
|
||||
}
|
||||
case "isrc", "tsrc":
|
||||
metadata.ISRC = value
|
||||
}
|
||||
}
|
||||
|
||||
metadata.UPC = firstPreferredFFprobeUPCValue(allTags)
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
|
||||
metadata, err := readMetadataWithFFprobe(filePath)
|
||||
if err != nil {
|
||||
return &AudioMetadata{}, nil
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
result := format
|
||||
|
||||
year := metadata.Year
|
||||
if len(year) >= 4 {
|
||||
year = year[:4]
|
||||
}
|
||||
|
||||
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
|
||||
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
|
||||
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
|
||||
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
|
||||
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
|
||||
result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year))
|
||||
result = strings.ReplaceAll(result, "{isrc}", sanitizeFilenameForRename(metadata.ISRC))
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
||||
} else {
|
||||
result = strings.ReplaceAll(result, "{track}", "")
|
||||
}
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
|
||||
} else {
|
||||
result = strings.ReplaceAll(result, "{disc}", "")
|
||||
}
|
||||
|
||||
result = strings.TrimSpace(result)
|
||||
result = strings.Join(strings.Fields(result), " ")
|
||||
|
||||
result = strings.Trim(result, " -._")
|
||||
|
||||
if result == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return result + ext
|
||||
}
|
||||
|
||||
func sanitizeFilenameForRename(name string) string {
|
||||
|
||||
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
|
||||
result := name
|
||||
for _, char := range invalid {
|
||||
result = strings.ReplaceAll(result, char, "")
|
||||
}
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func PreviewRename(files []string, format string) []RenamePreview {
|
||||
var previews []RenamePreview
|
||||
|
||||
for _, filePath := range files {
|
||||
preview := RenamePreview{
|
||||
OldPath: filePath,
|
||||
OldName: filepath.Base(filePath),
|
||||
}
|
||||
|
||||
metadata, err := ReadAudioMetadata(filePath)
|
||||
if err != nil {
|
||||
preview.Error = err.Error()
|
||||
previews = append(previews, preview)
|
||||
continue
|
||||
}
|
||||
|
||||
preview.Metadata = *metadata
|
||||
|
||||
ext := filepath.Ext(filePath)
|
||||
newName := GenerateFilename(metadata, format, ext)
|
||||
|
||||
if newName == "" {
|
||||
preview.Error = "Could not generate filename (missing metadata)"
|
||||
previews = append(previews, preview)
|
||||
continue
|
||||
}
|
||||
|
||||
preview.NewName = newName
|
||||
preview.NewPath = filepath.Join(filepath.Dir(filePath), newName)
|
||||
|
||||
previews = append(previews, preview)
|
||||
}
|
||||
|
||||
return previews
|
||||
}
|
||||
|
||||
func GetFileSizes(files []string) map[string]int64 {
|
||||
result := make(map[string]int64)
|
||||
for _, filePath := range files {
|
||||
info, err := os.Stat(filePath)
|
||||
if err == nil {
|
||||
result[filePath] = info.Size()
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func RenameFiles(files []string, format string) []RenameResult {
|
||||
var results []RenameResult
|
||||
|
||||
for _, filePath := range files {
|
||||
result := RenameResult{
|
||||
OldPath: filePath,
|
||||
}
|
||||
|
||||
metadata, err := ReadAudioMetadata(filePath)
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
result.Success = false
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
ext := filepath.Ext(filePath)
|
||||
newName := GenerateFilename(metadata, format, ext)
|
||||
|
||||
if newName == "" {
|
||||
result.Error = "Could not generate filename (missing metadata)"
|
||||
result.Success = false
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
newPath := filepath.Join(filepath.Dir(filePath), newName)
|
||||
result.NewPath = newPath
|
||||
|
||||
if newPath != filePath {
|
||||
if _, err := os.Stat(newPath); err == nil {
|
||||
result.Error = "File already exists"
|
||||
result.Success = false
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(filePath, newPath); err != nil {
|
||||
result.Error = err.Error()
|
||||
result.Success = false
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func buildFormattedFilenameBase(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner, isrc string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
|
||||
safeTitle := SanitizeFilename(trackName)
|
||||
safeArtist := SanitizeFilename(artistName)
|
||||
safeAlbum := SanitizeFilename(albumName)
|
||||
safeAlbumArtist := SanitizeFilename(albumArtist)
|
||||
safeISRC := SanitizeOptionalFilename(isrc)
|
||||
|
||||
safePlaylist := SanitizeFilename(playlistName)
|
||||
safeCreator := SanitizeFilename(playlistOwner)
|
||||
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
var filename string
|
||||
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
|
||||
filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
|
||||
filename = strings.ReplaceAll(filename, "{isrc}", safeISRC)
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title":
|
||||
filename = safeTitle
|
||||
default:
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename
|
||||
}
|
||||
|
||||
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool, extra ...string) string {
|
||||
isrc := ""
|
||||
if len(extra) > 0 {
|
||||
isrc = extra[0]
|
||||
}
|
||||
|
||||
return buildFormattedFilenameBase(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner, isrc, includeTrackNumber, position, discNumber, useAlbumTrackNumber) + ".flac"
|
||||
}
|
||||
|
||||
func ResolveOutputPathForDownload(path string, redownloadWithSuffix bool) (string, bool) {
|
||||
if !redownloadWithSuffix {
|
||||
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
||||
return path, true
|
||||
}
|
||||
return path, false
|
||||
}
|
||||
|
||||
if info, err := os.Stat(path); err != nil || info.Size() == 0 {
|
||||
return path, false
|
||||
}
|
||||
|
||||
ext := filepath.Ext(path)
|
||||
base := strings.TrimSuffix(path, ext)
|
||||
|
||||
for i := 1; ; i++ {
|
||||
candidate := fmt.Sprintf("%s_%02d%s", base, i, ext)
|
||||
if info, err := os.Stat(candidate); err != nil || info.Size() == 0 {
|
||||
return candidate, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustFileSize(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func SanitizeFilename(name string) string {
|
||||
|
||||
sanitized := strings.ReplaceAll(name, "/", " ")
|
||||
|
||||
re := regexp.MustCompile(`[<>:"\\|?*]`)
|
||||
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||
|
||||
var result strings.Builder
|
||||
for _, r := range sanitized {
|
||||
|
||||
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
if r == 0x7F {
|
||||
continue
|
||||
}
|
||||
|
||||
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
|
||||
result.WriteRune(r)
|
||||
}
|
||||
|
||||
sanitized = result.String()
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
|
||||
sanitized = strings.Trim(sanitized, ". ")
|
||||
|
||||
re = regexp.MustCompile(`\s+`)
|
||||
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||
|
||||
re = regexp.MustCompile(`_+`)
|
||||
sanitized = re.ReplaceAllString(sanitized, "_")
|
||||
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
|
||||
if sanitized == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
if !utf8.ValidString(sanitized) {
|
||||
|
||||
sanitized = strings.ToValidUTF8(sanitized, "_")
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func GetFirstArtist(artistString string) string {
|
||||
if artistString == "" {
|
||||
return ""
|
||||
}
|
||||
delimiters := []string{", ", " & ", " feat. ", " ft. ", " featuring "}
|
||||
for _, d := range delimiters {
|
||||
if idx := strings.Index(strings.ToLower(artistString), d); idx != -1 {
|
||||
return strings.TrimSpace(artistString[:idx])
|
||||
}
|
||||
}
|
||||
return artistString
|
||||
}
|
||||
|
||||
func NormalizePath(folderPath string) string {
|
||||
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
}
|
||||
|
||||
func GetSeparator() string {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return "; "
|
||||
}
|
||||
|
||||
if sep, ok := settings["separator"].(string); ok {
|
||||
if sep == "comma" {
|
||||
return ", "
|
||||
}
|
||||
if sep == "semicolon" {
|
||||
return "; "
|
||||
}
|
||||
}
|
||||
return "; "
|
||||
}
|
||||
|
||||
func SanitizeFolderPath(folderPath string) string {
|
||||
|
||||
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
|
||||
sep := string(filepath.Separator)
|
||||
|
||||
parts := strings.Split(normalizedPath, sep)
|
||||
sanitizedParts := make([]string, 0, len(parts))
|
||||
|
||||
for i, part := range parts {
|
||||
|
||||
if i == 0 && len(part) == 2 && part[1] == ':' {
|
||||
sanitizedParts = append(sanitizedParts, part)
|
||||
continue
|
||||
}
|
||||
|
||||
if i == 0 && part == "" {
|
||||
sanitizedParts = append(sanitizedParts, part)
|
||||
continue
|
||||
}
|
||||
|
||||
sanitized := sanitizeFolderName(part)
|
||||
if sanitized != "" {
|
||||
sanitizedParts = append(sanitizedParts, sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(sanitizedParts, sep)
|
||||
}
|
||||
|
||||
func sanitizeFolderName(name string) string { return SanitizeFilename(name) }
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
return SanitizeFilename(name)
|
||||
}
|
||||
|
||||
func SanitizeOptionalFilename(name string) string {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return ""
|
||||
}
|
||||
return SanitizeFilename(name)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
func OpenFolderInExplorer(path string) error {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.Command("explorer", path)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", path)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", path)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", path)
|
||||
}
|
||||
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
|
||||
|
||||
if defaultPath == "" {
|
||||
defaultPath = GetDefaultMusicPath()
|
||||
}
|
||||
|
||||
options := wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select Download Folder",
|
||||
DefaultDirectory: defaultPath,
|
||||
}
|
||||
|
||||
selectedPath, err := wailsRuntime.OpenDirectoryDialog(ctx, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if selectedPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return selectedPath, nil
|
||||
}
|
||||
|
||||
func SelectFileDialog(ctx context.Context) (string, error) {
|
||||
options := wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select Audio File for Analysis",
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{
|
||||
DisplayName: "Audio Files (*.flac;*.mp3;*.m4a;*.aac)",
|
||||
Pattern: "*.flac;*.mp3;*.m4a;*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "FLAC Audio Files (*.flac)",
|
||||
Pattern: "*.flac",
|
||||
},
|
||||
{
|
||||
DisplayName: "MP3 Audio Files (*.mp3)",
|
||||
Pattern: "*.mp3",
|
||||
},
|
||||
{
|
||||
DisplayName: "M4A Audio Files (*.m4a)",
|
||||
Pattern: "*.m4a",
|
||||
},
|
||||
{
|
||||
DisplayName: "AAC Audio Files (*.aac)",
|
||||
Pattern: "*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
selectedFile, err := wailsRuntime.OpenFileDialog(ctx, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if selectedFile == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return selectedFile, nil
|
||||
}
|
||||
|
||||
func SelectImageVideoDialog(ctx context.Context) ([]string, error) {
|
||||
options := wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select Image or Video",
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{
|
||||
DisplayName: "Supported Files (*.jpg, *.png, *.mp4, *.mov, ...)",
|
||||
Pattern: "*.jpg;*.jpeg;*.png;*.gif;*.webp;*.mp4;*.mkv;*.webm;*.mov",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
selectedPaths, err := wailsRuntime.OpenMultipleFilesDialog(ctx, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return selectedPaths, nil
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type HistoryItem struct {
|
||||
ID string `json:"id"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Title string `json:"title"`
|
||||
Artists string `json:"artists"`
|
||||
Album string `json:"album"`
|
||||
DurationStr string `json:"duration_str"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Quality string `json:"quality"`
|
||||
Format string `json:"format"`
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
var historyDB *bolt.DB
|
||||
|
||||
const (
|
||||
historyBucket = "DownloadHistory"
|
||||
maxHistory = 10000
|
||||
)
|
||||
|
||||
func InitHistoryDB(appName string) error {
|
||||
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dbPath := filepath.Join(appDir, "history.db")
|
||||
|
||||
db, err := bolt.Open(dbPath, 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.Update(func(tx *bolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
historyDB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloseHistoryDB() {
|
||||
if historyDB != nil {
|
||||
historyDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func AddHistoryItem(item HistoryItem, appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := b.NextSequence()
|
||||
|
||||
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
|
||||
item.Timestamp = time.Now().Unix()
|
||||
|
||||
buf, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.Stats().KeyN >= maxHistory {
|
||||
c := b.Cursor()
|
||||
|
||||
toDelete := maxHistory / 20
|
||||
if toDelete < 1 {
|
||||
toDelete = 1
|
||||
}
|
||||
|
||||
count := 0
|
||||
for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() {
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return b.Put([]byte(item.ID), buf)
|
||||
})
|
||||
}
|
||||
|
||||
func GetHistoryItems(appName string) ([]HistoryItem, error) {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var items []HistoryItem
|
||||
err := historyDB.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(historyBucket))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
c := b.Cursor()
|
||||
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
var item HistoryItem
|
||||
if err := json.Unmarshal(v, &item); err == nil {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].Timestamp > items[j].Timestamp
|
||||
})
|
||||
|
||||
return items, err
|
||||
}
|
||||
|
||||
func ClearHistory(appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
return tx.DeleteBucket([]byte(historyBucket))
|
||||
})
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
const (
|
||||
fetchHistoryBucket = "FetchHistory"
|
||||
)
|
||||
|
||||
func AddFetchHistoryItem(item FetchHistoryItem, appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(fetchHistoryBucket))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := b.NextSequence()
|
||||
|
||||
if item.URL != "" {
|
||||
c := b.Cursor()
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
var existing FetchHistoryItem
|
||||
if err := json.Unmarshal(v, &existing); err == nil {
|
||||
if existing.URL == item.URL && existing.Type == item.Type {
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
|
||||
item.Timestamp = time.Now().Unix()
|
||||
|
||||
buf, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.Stats().KeyN >= maxHistory {
|
||||
c := b.Cursor()
|
||||
toDelete := maxHistory / 20
|
||||
if toDelete < 1 {
|
||||
toDelete = 1
|
||||
}
|
||||
count := 0
|
||||
for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() {
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return b.Put([]byte(item.ID), buf)
|
||||
})
|
||||
}
|
||||
|
||||
func GetFetchHistoryItems(appName string) ([]FetchHistoryItem, error) {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var items []FetchHistoryItem
|
||||
err := historyDB.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(fetchHistoryBucket))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
c := b.Cursor()
|
||||
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
var item FetchHistoryItem
|
||||
if err := json.Unmarshal(v, &item); err == nil {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].Timestamp > items[j].Timestamp
|
||||
})
|
||||
|
||||
return items, err
|
||||
}
|
||||
|
||||
func ClearFetchHistory(appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
return tx.DeleteBucket([]byte(fetchHistoryBucket))
|
||||
})
|
||||
}
|
||||
|
||||
func ClearFetchHistoryByType(itemType string, appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(fetchHistoryBucket))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var keysToDelete [][]byte
|
||||
|
||||
c := b.Cursor()
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
var item FetchHistoryItem
|
||||
if err := json.Unmarshal(v, &item); err == nil {
|
||||
if item.Type == itemType {
|
||||
keysToDelete = append(keysToDelete, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range keysToDelete {
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteHistoryItem(id string, appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(historyBucket))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return b.Delete([]byte(id))
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteFetchHistoryItem(id string, appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(fetchHistoryBucket))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return b.Delete([]byte(id))
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const DefaultDownloaderUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||
|
||||
func NewRequestWithDefaultHeaders(method string, rawURL string, body io.Reader) (*http.Request, error) {
|
||||
req, err := http.NewRequest(method, rawURL, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", DefaultDownloaderUserAgent)
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
|
||||
return req, nil
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
const (
|
||||
isrcCacheDBFile = "isrc_cache.db"
|
||||
isrcCacheBucket = "SpotifyTrackISRC"
|
||||
)
|
||||
|
||||
type isrcCacheEntry struct {
|
||||
TrackID string `json:"track_id"`
|
||||
ISRC string `json:"isrc"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
var (
|
||||
isrcCacheDB *bolt.DB
|
||||
isrcCacheDBMu sync.Mutex
|
||||
)
|
||||
|
||||
func InitISRCCacheDB() error {
|
||||
isrcCacheDBMu.Lock()
|
||||
defer isrcCacheDBMu.Unlock()
|
||||
|
||||
if isrcCacheDB != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(appDir, isrcCacheDBFile)
|
||||
db, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Update(func(tx *bolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists([]byte(isrcCacheBucket))
|
||||
return err
|
||||
}); err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
isrcCacheDB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloseISRCCacheDB() {
|
||||
isrcCacheDBMu.Lock()
|
||||
defer isrcCacheDBMu.Unlock()
|
||||
|
||||
if isrcCacheDB != nil {
|
||||
_ = isrcCacheDB.Close()
|
||||
isrcCacheDB = nil
|
||||
}
|
||||
}
|
||||
|
||||
func GetCachedISRC(trackID string) (string, error) {
|
||||
normalizedTrackID := strings.TrimSpace(trackID)
|
||||
if normalizedTrackID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := InitISRCCacheDB(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var cachedISRC string
|
||||
err := isrcCacheDB.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(isrcCacheBucket))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
value := bucket.Get([]byte(normalizedTrackID))
|
||||
if len(value) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entry isrcCacheEntry
|
||||
if err := json.Unmarshal(value, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cachedISRC = strings.ToUpper(strings.TrimSpace(entry.ISRC))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cachedISRC, nil
|
||||
}
|
||||
|
||||
func PutCachedISRC(trackID string, isrc string) error {
|
||||
normalizedTrackID := strings.TrimSpace(trackID)
|
||||
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if normalizedTrackID == "" || normalizedISRC == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := InitISRCCacheDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := isrcCacheEntry{
|
||||
TrackID: normalizedTrackID,
|
||||
ISRC: normalizedISRC,
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode ISRC cache entry: %w", err)
|
||||
}
|
||||
|
||||
return isrcCacheDB.Update(func(tx *bolt.Tx) error {
|
||||
bucket, err := tx.CreateBucketIfNotExists([]byte(isrcCacheBucket))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bucket.Put([]byte(normalizedTrackID), payload)
|
||||
})
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
spotifySessionTokenURL = "https://open.spotify.com/api/token"
|
||||
spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token"
|
||||
spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
spotifyTokenCacheFile = ".isrc-finder-token.json"
|
||||
)
|
||||
|
||||
var spotifyAnonymousTokenMu sync.Mutex
|
||||
|
||||
type spotifyAnonymousToken struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"`
|
||||
}
|
||||
|
||||
type spotifyTrackRawData struct {
|
||||
Album struct {
|
||||
GID string `json:"gid"`
|
||||
} `json:"album"`
|
||||
ExternalID []struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
} `json:"external_id"`
|
||||
}
|
||||
|
||||
type spotifyAlbumRawData struct {
|
||||
ExternalID []struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
} `json:"external_id"`
|
||||
}
|
||||
|
||||
type SpotifyTrackIdentifiers struct {
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
UPC string `json:"upc,omitempty"`
|
||||
}
|
||||
|
||||
func GetSpotifyTrackIdentifiersDirect(spotifyTrackID string) (SpotifyTrackIdentifiers, error) {
|
||||
normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID)
|
||||
if err != nil {
|
||||
return SpotifyTrackIdentifiers{}, err
|
||||
}
|
||||
|
||||
identifiers := SpotifyTrackIdentifiers{}
|
||||
|
||||
cachedISRC, err := GetCachedISRC(normalizedTrackID)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to read ISRC cache: %v\n", err)
|
||||
} else if cachedISRC != "" {
|
||||
fmt.Printf("Found ISRC in cache: %s\n", cachedISRC)
|
||||
identifiers.ISRC = cachedISRC
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
payload, metadataErr := fetchSpotifyTrackRawData(httpClient, normalizedTrackID)
|
||||
if metadataErr == nil {
|
||||
metadataIdentifiers, extractErr := extractSpotifyTrackIdentifiers(httpClient, payload)
|
||||
if extractErr == nil {
|
||||
mergeSpotifyTrackIdentifiers(&identifiers, metadataIdentifiers)
|
||||
if identifiers.ISRC != "" {
|
||||
fmt.Printf("Found identifiers via Spotify metadata: isrc=%s upc=%s\n", identifiers.ISRC, identifiers.UPC)
|
||||
cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", identifiers.ISRC)
|
||||
}
|
||||
if identifiers.ISRC != "" && identifiers.UPC != "" {
|
||||
return identifiers, nil
|
||||
}
|
||||
}
|
||||
metadataErr = extractErr
|
||||
}
|
||||
|
||||
if metadataErr != nil {
|
||||
fmt.Printf("Warning: Spotify metadata identifier lookup failed, falling back to Soundplate: %v\n", metadataErr)
|
||||
}
|
||||
|
||||
if identifiers.ISRC == "" {
|
||||
client := NewSongLinkClient()
|
||||
isrc, resolvedTrackID, soundplateErr := client.lookupSpotifyISRCViaSoundplate(normalizedTrackID)
|
||||
if soundplateErr == nil && isrc != "" {
|
||||
identifiers.ISRC = isrc
|
||||
fmt.Printf("Found ISRC via Soundplate: %s\n", isrc)
|
||||
cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc)
|
||||
return identifiers, nil
|
||||
}
|
||||
|
||||
if metadataErr != nil && soundplateErr != nil {
|
||||
return identifiers, fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr)
|
||||
}
|
||||
if soundplateErr != nil && identifiers.UPC == "" {
|
||||
return identifiers, soundplateErr
|
||||
}
|
||||
}
|
||||
|
||||
if identifiers.ISRC != "" || identifiers.UPC != "" {
|
||||
return identifiers, nil
|
||||
}
|
||||
if metadataErr != nil {
|
||||
return identifiers, metadataErr
|
||||
}
|
||||
|
||||
return identifiers, fmt.Errorf("no Spotify identifiers found for track %s", normalizedTrackID)
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
|
||||
identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if identifiers.ISRC == "" {
|
||||
return "", fmt.Errorf("no Spotify ISRC found for track %s", strings.TrimSpace(spotifyTrackID))
|
||||
}
|
||||
|
||||
return identifiers.ISRC, nil
|
||||
}
|
||||
|
||||
func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc string) {
|
||||
if err := PutCachedISRC(trackID, isrc); err != nil {
|
||||
fmt.Printf("Warning: failed to write ISRC cache: %v\n", err)
|
||||
}
|
||||
if resolvedTrackID != "" && resolvedTrackID != trackID {
|
||||
if err := PutCachedISRC(resolvedTrackID, isrc); err != nil {
|
||||
fmt.Printf("Warning: failed to write ISRC cache for resolved track ID: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeSpotifyTrackIdentifiers(target *SpotifyTrackIdentifiers, incoming SpotifyTrackIdentifiers) {
|
||||
if incoming.ISRC != "" {
|
||||
target.ISRC = strings.TrimSpace(incoming.ISRC)
|
||||
}
|
||||
if incoming.UPC != "" {
|
||||
target.UPC = strings.TrimSpace(incoming.UPC)
|
||||
}
|
||||
}
|
||||
|
||||
func lookupSpotifyAlbumUPC(albumID string) (string, error) {
|
||||
normalizedAlbumID := strings.TrimSpace(albumID)
|
||||
if normalizedAlbumID == "" {
|
||||
return "", fmt.Errorf("spotify album ID is required")
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
payload, err := fetchSpotifyAlbumRawData(httpClient, normalizedAlbumID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return extractSpotifyAlbumUPC(payload)
|
||||
}
|
||||
|
||||
func requestSpotifyBytes(client *http.Client, targetURL string, headers map[string]string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
details := strings.TrimSpace(string(body))
|
||||
if details == "" {
|
||||
details = resp.Status
|
||||
}
|
||||
return nil, fmt.Errorf("request failed: %s", details)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func requestSpotifyJSON(client *http.Client, targetURL string, headers map[string]string, target interface{}) error {
|
||||
body, err := requestSpotifyBytes(client, targetURL, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, target); err != nil {
|
||||
return fmt.Errorf("failed to parse JSON response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSpotifyCachedToken() (*spotifyAnonymousToken, error) {
|
||||
cachePath, err := spotifyTokenCachePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read token cache: %w", err)
|
||||
}
|
||||
|
||||
var token spotifyAnonymousToken
|
||||
if err := json.Unmarshal(body, &token); err != nil {
|
||||
return nil, fmt.Errorf("failed to read token cache: %w", err)
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func saveSpotifyCachedToken(token *spotifyAnonymousToken) error {
|
||||
cachePath, err := spotifyTokenCachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create token cache directory: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.MarshalIndent(token, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cachePath, body, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write token cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func spotifyTokenCachePath() (string, error) {
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(appDir, spotifyTokenCacheFile), nil
|
||||
}
|
||||
|
||||
func spotifyTokenIsValid(token *spotifyAnonymousToken) bool {
|
||||
if token == nil || token.AccessToken == "" || token.AccessTokenExpirationTimestampMs == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Now().UnixMilli() < token.AccessTokenExpirationTimestampMs-30_000
|
||||
}
|
||||
|
||||
func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
|
||||
spotifyAnonymousTokenMu.Lock()
|
||||
defer spotifyAnonymousTokenMu.Unlock()
|
||||
|
||||
cachedToken, err := loadSpotifyCachedToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if spotifyTokenIsValid(cachedToken) {
|
||||
return cachedToken.AccessToken, nil
|
||||
}
|
||||
|
||||
generatedTOTP, version, err := generateSpotifyTOTP(time.Now())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate Spotify TOTP: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{
|
||||
"reason": {"init"},
|
||||
"productType": {"web-player"},
|
||||
"totp": {generatedTOTP},
|
||||
"totpServer": {generatedTOTP},
|
||||
"totpVer": {strconv.Itoa(version)},
|
||||
}
|
||||
|
||||
var token spotifyAnonymousToken
|
||||
if err := requestSpotifyJSON(client, spotifySessionTokenURL+"?"+query.Encode(), nil, &token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := saveSpotifyCachedToken(&token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
|
||||
func extractSpotifyTrackID(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", errors.New("track input is required")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(value, "spotify:track:") {
|
||||
return value[strings.LastIndex(value, ":")+1:], nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(value)
|
||||
if err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") {
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
if len(parts) >= 2 && parts[0] == "track" {
|
||||
return parts[1], nil
|
||||
}
|
||||
return "", errors.New("expected URL like https://open.spotify.com/track/<id>")
|
||||
}
|
||||
|
||||
if len(value) == 22 {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
return "", errors.New("track must be a Spotify track ID, URL, or URI")
|
||||
}
|
||||
|
||||
func spotifyTrackIDToGID(trackID string) (string, error) {
|
||||
return spotifyEntityIDToGID(trackID)
|
||||
}
|
||||
|
||||
func spotifyEntityIDToGID(entityID string) (string, error) {
|
||||
if entityID == "" {
|
||||
return "", errors.New("entity ID is empty")
|
||||
}
|
||||
|
||||
value := big.NewInt(0)
|
||||
base := big.NewInt(62)
|
||||
|
||||
for _, char := range entityID {
|
||||
index := strings.IndexRune(spotifyBase62Alphabet, char)
|
||||
if index < 0 {
|
||||
return "", fmt.Errorf("invalid base62 character: %q", string(char))
|
||||
}
|
||||
|
||||
value.Mul(value, base)
|
||||
value.Add(value, big.NewInt(int64(index)))
|
||||
}
|
||||
|
||||
hexValue := value.Text(16)
|
||||
if len(hexValue) < 32 {
|
||||
hexValue = strings.Repeat("0", 32-len(hexValue)) + hexValue
|
||||
}
|
||||
|
||||
return hexValue, nil
|
||||
}
|
||||
|
||||
func fetchSpotifyTrackRawData(client *http.Client, trackID string) ([]byte, error) {
|
||||
gid, err := spotifyTrackIDToGID(trackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fetchSpotifyRawMetadataByGID(client, "track", gid)
|
||||
}
|
||||
|
||||
func fetchSpotifyAlbumRawData(client *http.Client, albumID string) ([]byte, error) {
|
||||
gid, err := spotifyEntityIDToGID(albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fetchSpotifyRawMetadataByGID(client, "album", gid)
|
||||
}
|
||||
|
||||
func fetchSpotifyRawMetadataByGID(client *http.Client, entityType string, gid string) ([]byte, error) {
|
||||
accessToken, err := requestSpotifyAnonymousAccessToken(client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return requestSpotifyBytes(
|
||||
client,
|
||||
fmt.Sprintf(spotifyGIDMetadataURL, entityType, gid),
|
||||
map[string]string{
|
||||
"authorization": "Bearer " + accessToken,
|
||||
"accept": "application/json",
|
||||
"user-agent": songLinkUserAgent,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func extractSpotifyTrackIdentifiers(client *http.Client, payload []byte) (SpotifyTrackIdentifiers, error) {
|
||||
var track spotifyTrackRawData
|
||||
if err := json.Unmarshal(payload, &track); err != nil {
|
||||
return SpotifyTrackIdentifiers{}, fmt.Errorf("failed to decode Spotify track metadata: %w", err)
|
||||
}
|
||||
|
||||
identifiers := SpotifyTrackIdentifiers{}
|
||||
for _, externalID := range track.ExternalID {
|
||||
if strings.EqualFold(strings.TrimSpace(externalID.Type), "isrc") {
|
||||
if isrc := firstISRCMatch(externalID.ID); isrc != "" {
|
||||
identifiers.ISRC = isrc
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if identifiers.ISRC == "" {
|
||||
identifiers.ISRC = firstISRCMatch(string(payload))
|
||||
}
|
||||
|
||||
albumGID := strings.TrimSpace(track.Album.GID)
|
||||
if client != nil && albumGID != "" {
|
||||
albumPayload, err := fetchSpotifyRawMetadataByGID(client, "album", albumGID)
|
||||
if err == nil {
|
||||
if upc, upcErr := extractSpotifyAlbumUPC(albumPayload); upcErr == nil {
|
||||
identifiers.UPC = upc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return identifiers, nil
|
||||
}
|
||||
|
||||
func extractSpotifyTrackISRC(payload []byte) (string, error) {
|
||||
identifiers, err := extractSpotifyTrackIdentifiers(nil, payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if identifiers.ISRC != "" {
|
||||
return identifiers.ISRC, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ISRC not found in Spotify track metadata")
|
||||
}
|
||||
|
||||
func extractSpotifyAlbumUPC(payload []byte) (string, error) {
|
||||
var album spotifyAlbumRawData
|
||||
if err := json.Unmarshal(payload, &album); err != nil {
|
||||
return "", fmt.Errorf("failed to decode Spotify album metadata: %w", err)
|
||||
}
|
||||
|
||||
for _, externalID := range album.ExternalID {
|
||||
if strings.EqualFold(strings.TrimSpace(externalID.Type), "upc") {
|
||||
upc := strings.TrimSpace(externalID.ID)
|
||||
if upc != "" {
|
||||
return upc, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("UPC not found in Spotify album metadata")
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package backend
|
||||
|
||||
import "strings"
|
||||
|
||||
func ResolveTrackISRC(spotifyTrackID string) string {
|
||||
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
|
||||
if spotifyTrackID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if cachedISRC, err := GetCachedISRC(spotifyTrackID); err == nil && cachedISRC != "" {
|
||||
return strings.ToUpper(strings.TrimSpace(cachedISRC))
|
||||
}
|
||||
|
||||
client := NewSongLinkClient()
|
||||
isrc, err := client.GetISRCDirect(spotifyTrackID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.ToUpper(strings.TrimSpace(isrc))
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type resolvedTrackLinks struct {
|
||||
TidalURL string
|
||||
AmazonURL string
|
||||
DeezerURL string
|
||||
ISRC string
|
||||
}
|
||||
|
||||
const (
|
||||
linkResolverProviderSongstats = "songstats"
|
||||
linkResolverProviderDeezerSongLink = "deezer-songlink"
|
||||
)
|
||||
|
||||
func (s *SongLinkClient) resolveSpotifyTrackLinks(spotifyTrackID string, region string) (*resolvedTrackLinks, error) {
|
||||
links := &resolvedTrackLinks{}
|
||||
var attempts []string
|
||||
|
||||
isrc, err := s.lookupSpotifyISRC(spotifyTrackID)
|
||||
if err != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("spotify isrc: %v", err))
|
||||
} else {
|
||||
links.ISRC = isrc
|
||||
}
|
||||
|
||||
if links.ISRC != "" {
|
||||
resolvers := orderedLinkResolvers()
|
||||
|
||||
for _, resolver := range resolvers {
|
||||
switch resolver {
|
||||
case linkResolverProviderSongstats:
|
||||
addedData, songstatsErr := s.resolveLinksViaSongstats(links)
|
||||
if songstatsErr != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("songstats: %v", songstatsErr))
|
||||
} else if addedData {
|
||||
fmt.Println("Using Songstats as configured link resolver")
|
||||
}
|
||||
case linkResolverProviderDeezerSongLink:
|
||||
addedData, deezerSongLinkErr := s.resolveLinksViaDeezerSongLink(links, region)
|
||||
if deezerSongLinkErr != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("deezer-songlink: %v", deezerSongLinkErr))
|
||||
} else if addedData {
|
||||
fmt.Println("Using Songlink as configured link resolver")
|
||||
}
|
||||
}
|
||||
|
||||
if links.TidalURL != "" && links.AmazonURL != "" {
|
||||
return links, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasAnySongLinkData(links) {
|
||||
return links, nil
|
||||
}
|
||||
|
||||
if len(attempts) == 0 {
|
||||
attempts = append(attempts, "no streaming URLs found")
|
||||
}
|
||||
|
||||
return links, errors.New(strings.Join(attempts, " | "))
|
||||
}
|
||||
|
||||
func orderedLinkResolvers() []string {
|
||||
preferred := GetLinkResolverSetting()
|
||||
if !GetLinkResolverAllowFallback() {
|
||||
if preferred == linkResolverProviderDeezerSongLink {
|
||||
return []string{linkResolverProviderDeezerSongLink}
|
||||
}
|
||||
return []string{linkResolverProviderSongstats}
|
||||
}
|
||||
|
||||
if preferred == linkResolverProviderDeezerSongLink {
|
||||
return []string{
|
||||
linkResolverProviderDeezerSongLink,
|
||||
linkResolverProviderSongstats,
|
||||
}
|
||||
}
|
||||
|
||||
return []string{
|
||||
linkResolverProviderSongstats,
|
||||
linkResolverProviderDeezerSongLink,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) resolveLinksViaSongstats(links *resolvedTrackLinks) (bool, error) {
|
||||
if links == nil || links.ISRC == "" {
|
||||
return false, fmt.Errorf("ISRC is required for Songstats resolver")
|
||||
}
|
||||
|
||||
before := *links
|
||||
|
||||
fmt.Printf("Fetching Songstats links for ISRC %s\n", links.ISRC)
|
||||
if err := s.populateLinksFromSongstats(links, links.ISRC); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return *links != before, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) resolveLinksViaDeezerSongLink(links *resolvedTrackLinks, region string) (bool, error) {
|
||||
if links == nil || links.ISRC == "" {
|
||||
return false, fmt.Errorf("ISRC is required for Deezer song.link resolver")
|
||||
}
|
||||
|
||||
before := *links
|
||||
var attempts []string
|
||||
|
||||
if links.DeezerURL == "" {
|
||||
fmt.Printf("Resolving Deezer track from ISRC %s\n", links.ISRC)
|
||||
deezerURL, err := s.lookupDeezerTrackURLByISRC(links.ISRC)
|
||||
if err != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("deezer isrc: %v", err))
|
||||
} else {
|
||||
links.DeezerURL = deezerURL
|
||||
fmt.Printf("Found Deezer URL: %s\n", links.DeezerURL)
|
||||
}
|
||||
}
|
||||
|
||||
if links.DeezerURL != "" {
|
||||
fmt.Println("Resolving streaming URLs from song.link via Deezer URL...")
|
||||
deezerResp, err := s.fetchSongLinkLinksByURL(links.DeezerURL, region)
|
||||
if err != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("song.link deezer: %v", err))
|
||||
} else {
|
||||
mergeSongLinkResponse(links, deezerResp)
|
||||
}
|
||||
|
||||
if links.ISRC == "" {
|
||||
if resolvedISRC, deezerISRCErr := getDeezerISRC(links.DeezerURL); deezerISRCErr == nil {
|
||||
links.ISRC = resolvedISRC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if *links != before {
|
||||
if len(attempts) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
return true, errors.New(strings.Join(attempts, " | "))
|
||||
}
|
||||
|
||||
if len(attempts) == 0 {
|
||||
attempts = append(attempts, "no links found via deezer-songlink")
|
||||
}
|
||||
|
||||
return false, errors.New(strings.Join(attempts, " | "))
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LRCLibResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Duration float64 `json:"duration"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
PlainLyrics string `json:"plainLyrics"`
|
||||
SyncedLyrics string `json:"syncedLyrics"`
|
||||
}
|
||||
|
||||
type LyricsLine struct {
|
||||
StartTimeMs string `json:"startTimeMs"`
|
||||
Words string `json:"words"`
|
||||
EndTimeMs string `json:"endTimeMs"`
|
||||
}
|
||||
|
||||
type LyricsResponse struct {
|
||||
Error bool `json:"error"`
|
||||
SyncType string `json:"syncType"`
|
||||
Lines []LyricsLine `json:"lines"`
|
||||
}
|
||||
|
||||
type LyricsDownloadRequest struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ISRC string `json:"isrc"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
TrackNumber bool `json:"track_number"`
|
||||
Position int `json:"position"`
|
||||
UseAlbumTrackNumber bool `json:"use_album_track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
}
|
||||
|
||||
type LyricsDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
type LyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewLyricsClient() *LyricsClient {
|
||||
return &LyricsClient{
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName, albumName string, duration int) (*LyricsResponse, error) {
|
||||
|
||||
apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s",
|
||||
url.QueryEscape(artistName),
|
||||
url.QueryEscape(trackName))
|
||||
|
||||
if albumName != "" {
|
||||
apiURL = fmt.Sprintf("%s&album_name=%s", apiURL, url.QueryEscape(albumName))
|
||||
}
|
||||
|
||||
if duration > 0 {
|
||||
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("LRCLIB returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read LRCLIB response: %v", err)
|
||||
}
|
||||
|
||||
var lrcLibResp LRCLibResponse
|
||||
if err := json.Unmarshal(body, &lrcLibResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
|
||||
}
|
||||
|
||||
if lrcLibResp.SyncedLyrics == "" && lrcLibResp.PlainLyrics == "" {
|
||||
return nil, fmt.Errorf("LRCLIB returned empty lyrics")
|
||||
}
|
||||
|
||||
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
|
||||
}
|
||||
|
||||
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
|
||||
resp := &LyricsResponse{
|
||||
Error: false,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Lines: []LyricsLine{},
|
||||
}
|
||||
|
||||
lyricsText := lrcLib.SyncedLyrics
|
||||
if lyricsText == "" {
|
||||
lyricsText = lrcLib.PlainLyrics
|
||||
resp.SyncType = "UNSYNCED"
|
||||
}
|
||||
|
||||
if lyricsText == "" {
|
||||
resp.Error = true
|
||||
return resp
|
||||
}
|
||||
|
||||
lines := strings.Split(lyricsText, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "[") && len(line) > 10 {
|
||||
closeBracket := strings.Index(line, "]")
|
||||
if closeBracket > 0 {
|
||||
timestamp := line[1:closeBracket]
|
||||
words := strings.TrimSpace(line[closeBracket+1:])
|
||||
|
||||
ms := lrcTimestampToMs(timestamp)
|
||||
resp.Lines = append(resp.Lines, LyricsLine{
|
||||
StartTimeMs: fmt.Sprintf("%d", ms),
|
||||
Words: words,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
resp.Lines = append(resp.Lines, LyricsLine{
|
||||
StartTimeMs: "",
|
||||
Words: line,
|
||||
})
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func lrcTimestampToMs(timestamp string) int64 {
|
||||
var minutes, seconds, centiseconds int64
|
||||
|
||||
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds)
|
||||
if n >= 2 {
|
||||
return minutes*60*1000 + seconds*1000 + centiseconds*10
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
|
||||
|
||||
apiURL := fmt.Sprintf("https://lrclib.net/api/search?artist_name=%s&track_name=%s",
|
||||
url.QueryEscape(artistName),
|
||||
url.QueryEscape(trackName))
|
||||
|
||||
resp, err := c.httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read failed: %v", err)
|
||||
}
|
||||
|
||||
var results []LRCLibResponse
|
||||
if err := json.Unmarshal(body, &results); err != nil {
|
||||
return nil, fmt.Errorf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, fmt.Errorf("no results found")
|
||||
}
|
||||
|
||||
var bestSynced *LRCLibResponse
|
||||
var bestPlain *LRCLibResponse
|
||||
for i := range results {
|
||||
if results[i].SyncedLyrics != "" && bestSynced == nil {
|
||||
bestSynced = &results[i]
|
||||
}
|
||||
if results[i].PlainLyrics != "" && bestPlain == nil {
|
||||
bestPlain = &results[i]
|
||||
}
|
||||
if bestSynced != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
best := bestSynced
|
||||
if best == nil {
|
||||
best = bestPlain
|
||||
}
|
||||
if best == nil {
|
||||
best = &results[0]
|
||||
}
|
||||
|
||||
if best.SyncedLyrics == "" && best.PlainLyrics == "" {
|
||||
return nil, fmt.Errorf("no lyrics found in search results")
|
||||
}
|
||||
|
||||
return c.convertLRCLibToLyricsResponse(best), nil
|
||||
}
|
||||
|
||||
func simplifyTrackName(name string) string {
|
||||
|
||||
if idx := strings.Index(name, "("); idx > 0 {
|
||||
name = strings.TrimSpace(name[:idx])
|
||||
}
|
||||
|
||||
if idx := strings.Index(name, " - "); idx > 0 {
|
||||
name = strings.TrimSpace(name[:idx])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func isSynced(resp *LyricsResponse) bool {
|
||||
return resp != nil && !resp.Error && resp.SyncType == "LINE_SYNCED" && len(resp.Lines) > 0
|
||||
}
|
||||
|
||||
func hasLyrics(resp *LyricsResponse) bool {
|
||||
return resp != nil && !resp.Error && len(resp.Lines) > 0
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName, albumName string, duration int) (*LyricsResponse, string, error) {
|
||||
|
||||
var unsyncedFallback *LyricsResponse
|
||||
var unsyncedSource string
|
||||
|
||||
check := func(resp *LyricsResponse, err error, source string) (*LyricsResponse, string, bool) {
|
||||
if err != nil || resp == nil || resp.Error || len(resp.Lines) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
if isSynced(resp) {
|
||||
return resp, source, true
|
||||
}
|
||||
|
||||
if unsyncedFallback == nil {
|
||||
unsyncedFallback = resp
|
||||
unsyncedSource = source
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
var resp *LyricsResponse
|
||||
var src string
|
||||
var found bool
|
||||
|
||||
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, albumName, duration)
|
||||
resp, src, found = check(resp, nil, "LRCLIB")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via exact match (with album)\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact (with album): no synced\n")
|
||||
|
||||
if albumName != "" {
|
||||
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, "", duration)
|
||||
resp, src, found = check(resp, nil, "LRCLIB (no album)")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via exact match (no album)\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact (no album): no synced\n")
|
||||
}
|
||||
|
||||
resp, _ = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
||||
resp, src, found = check(resp, nil, "LRCLIB Search")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via search\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB search: no synced\n")
|
||||
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
|
||||
|
||||
resp, _ = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, albumName, duration)
|
||||
resp, src, found = check(resp, nil, "LRCLIB (simplified)")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via simplified exact\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
|
||||
resp, _ = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
||||
resp, src, found = check(resp, nil, "LRCLIB Search (simplified)")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via simplified search\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
}
|
||||
|
||||
if unsyncedFallback != nil {
|
||||
fmt.Printf(" [LRCLIB] No synced found, using unsynced from: %s\n", unsyncedSource)
|
||||
return unsyncedFallback, unsyncedSource + " (unsynced)", nil
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("lyrics not found in any source")
|
||||
}
|
||||
|
||||
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||
sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||
sb.WriteString("[by:SpotiFlac]\n")
|
||||
sb.WriteString("\n")
|
||||
|
||||
for _, line := range lyrics.Lines {
|
||||
if line.Words == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if line.StartTimeMs == "" {
|
||||
sb.WriteString(fmt.Sprintf("%s\n", line.Words))
|
||||
} else {
|
||||
|
||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func msToLRCTimestamp(msStr string) string {
|
||||
var ms int64
|
||||
fmt.Sscanf(msStr, "%d", &ms)
|
||||
|
||||
totalSeconds := ms / 1000
|
||||
minutes := totalSeconds / 60
|
||||
seconds := totalSeconds % 60
|
||||
centiseconds := (ms % 1000) / 10
|
||||
|
||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, isrc string, includeTrackNumber bool, position, discNumber int) string {
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
safeAlbum := sanitizeFilename(albumName)
|
||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||
safeISRC := SanitizeOptionalFilename(isrc)
|
||||
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
var filename string
|
||||
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||
filename = strings.ReplaceAll(filename, "{isrc}", safeISRC)
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title":
|
||||
filename = safeTitle
|
||||
default:
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".lrc"
|
||||
}
|
||||
|
||||
func findAudioFileForLyrics(dir, trackName, artistName string) string {
|
||||
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
|
||||
audioExts := []string{".flac", ".mp3", ".m4a", ".FLAC", ".MP3", ".M4A"}
|
||||
|
||||
patterns := []string{
|
||||
fmt.Sprintf("%s - %s", safeTitle, safeArtist),
|
||||
fmt.Sprintf("%s - %s", safeArtist, safeTitle),
|
||||
safeTitle,
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := entry.Name()
|
||||
baseName := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if strings.HasPrefix(baseName, pattern) || strings.Contains(baseName, pattern) {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
for _, audioExt := range audioExts {
|
||||
if ext == strings.ToLower(audioExt) {
|
||||
return filepath.Join(dir, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) {
|
||||
if req.SpotifyID == "" {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Spotify ID is required",
|
||||
}, fmt.Errorf("spotify ID is required")
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create output directory: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
filenameFormat := req.FilenameFormat
|
||||
if filenameFormat == "" {
|
||||
filenameFormat = "title-artist"
|
||||
}
|
||||
resolvedISRC := strings.TrimSpace(req.ISRC)
|
||||
if resolvedISRC == "" && strings.Contains(filenameFormat, "{isrc}") {
|
||||
resolvedISRC = ResolveTrackISRC(req.SpotifyID)
|
||||
}
|
||||
filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, resolvedISRC, req.TrackNumber, req.Position, req.DiscNumber)
|
||||
filePath := filepath.Join(outputDir, filename)
|
||||
|
||||
filePath, alreadyExists := ResolveOutputPathForDownload(filePath, GetRedownloadWithSuffixSetting())
|
||||
if alreadyExists {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Lyrics file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
audioDuration := 0
|
||||
audioFile := findAudioFileForLyrics(outputDir, req.TrackName, req.ArtistName)
|
||||
if audioFile != "" {
|
||||
duration, err := GetAudioDuration(audioFile)
|
||||
if err == nil && duration > 0 {
|
||||
audioDuration = int(duration)
|
||||
fmt.Printf("[DownloadLyrics] Found audio file, duration: %d seconds\n", audioDuration)
|
||||
}
|
||||
}
|
||||
|
||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.AlbumName, audioDuration)
|
||||
if err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName)
|
||||
|
||||
if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write LRC file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &LyricsDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Lyrics downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
-1255
File diff suppressed because it is too large
Load Diff
@@ -1,331 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var AppVersion = "Unknown"
|
||||
|
||||
const (
|
||||
musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
|
||||
musicBrainzRequestTimeout = 10 * time.Second
|
||||
musicBrainzRequestRetries = 3
|
||||
musicBrainzRequestRetryWait = 3 * time.Second
|
||||
musicBrainzMinRequestInterval = 1100 * time.Millisecond
|
||||
musicBrainzThrottleCooldownOn503 = 5 * time.Second
|
||||
musicBrainzStatusCheckSkipWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
type musicBrainzStatusError struct {
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (e *musicBrainzStatusError) Error() string {
|
||||
return fmt.Sprintf("MusicBrainz API returned status: %d", e.StatusCode)
|
||||
}
|
||||
|
||||
type musicBrainzInflightCall struct {
|
||||
done chan struct{}
|
||||
result Metadata
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
musicBrainzCache sync.Map
|
||||
musicBrainzInflightMu sync.Mutex
|
||||
musicBrainzInflight = make(map[string]*musicBrainzInflightCall)
|
||||
|
||||
musicBrainzThrottleMu sync.Mutex
|
||||
musicBrainzNextRequest time.Time
|
||||
musicBrainzBlockedTill time.Time
|
||||
|
||||
musicBrainzStatusMu sync.RWMutex
|
||||
musicBrainzLastCheckedAt time.Time
|
||||
musicBrainzLastCheckedOnline bool
|
||||
)
|
||||
|
||||
func SetMusicBrainzStatusCheckResult(online bool) {
|
||||
musicBrainzStatusMu.Lock()
|
||||
defer musicBrainzStatusMu.Unlock()
|
||||
|
||||
musicBrainzLastCheckedAt = time.Now()
|
||||
musicBrainzLastCheckedOnline = online
|
||||
}
|
||||
|
||||
func ShouldSkipMusicBrainzMetadataFetch() bool {
|
||||
musicBrainzStatusMu.RLock()
|
||||
defer musicBrainzStatusMu.RUnlock()
|
||||
|
||||
if musicBrainzLastCheckedAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
|
||||
if musicBrainzLastCheckedOnline {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Since(musicBrainzLastCheckedAt) <= musicBrainzStatusCheckSkipWindow
|
||||
}
|
||||
|
||||
type MusicBrainzRecordingResponse struct {
|
||||
Recordings []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Length int `json:"length"`
|
||||
Releases []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
ReleaseGroup struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
} `json:"release-group"`
|
||||
Date string `json:"date"`
|
||||
Country string `json:"country"`
|
||||
Media []struct {
|
||||
Format string `json:"format"`
|
||||
} `json:"media"`
|
||||
LabelInfo []struct {
|
||||
Label struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"label"`
|
||||
} `json:"label-info"`
|
||||
} `json:"releases"`
|
||||
ArtistCredit []struct {
|
||||
Artist struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"artist"`
|
||||
} `json:"artist-credit"`
|
||||
Tags []struct {
|
||||
Count int `json:"count"`
|
||||
Name string `json:"name"`
|
||||
} `json:"tags"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
|
||||
func musicBrainzCacheKey(isrc string, useSingleGenre bool) string {
|
||||
separator := strings.TrimSpace(GetSeparator())
|
||||
if separator == "" {
|
||||
separator = ";"
|
||||
}
|
||||
|
||||
return strings.ToUpper(strings.TrimSpace(isrc)) + "|" + fmt.Sprintf("%t", useSingleGenre) + "|" + separator
|
||||
}
|
||||
|
||||
func waitForMusicBrainzRequestSlot() {
|
||||
musicBrainzThrottleMu.Lock()
|
||||
|
||||
readyAt := musicBrainzNextRequest
|
||||
if musicBrainzBlockedTill.After(readyAt) {
|
||||
readyAt = musicBrainzBlockedTill
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if readyAt.Before(now) {
|
||||
readyAt = now
|
||||
}
|
||||
|
||||
musicBrainzNextRequest = readyAt.Add(musicBrainzMinRequestInterval)
|
||||
waitDuration := time.Until(readyAt)
|
||||
|
||||
musicBrainzThrottleMu.Unlock()
|
||||
|
||||
if waitDuration > 0 {
|
||||
time.Sleep(waitDuration)
|
||||
}
|
||||
}
|
||||
|
||||
func noteMusicBrainzThrottle() {
|
||||
musicBrainzThrottleMu.Lock()
|
||||
defer musicBrainzThrottleMu.Unlock()
|
||||
|
||||
cooldownUntil := time.Now().Add(musicBrainzThrottleCooldownOn503)
|
||||
if cooldownUntil.After(musicBrainzBlockedTill) {
|
||||
musicBrainzBlockedTill = cooldownUntil
|
||||
}
|
||||
if musicBrainzNextRequest.Before(musicBrainzBlockedTill) {
|
||||
musicBrainzNextRequest = musicBrainzBlockedTill
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRetryMusicBrainzRequest(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
statusErr, ok := err.(*musicBrainzStatusError)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return statusErr.StatusCode == http.StatusServiceUnavailable || statusErr.StatusCode >= http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func queryMusicBrainzRecordings(client *http.Client, query string) (*MusicBrainzRecordingResponse, error) {
|
||||
reqURL := fmt.Sprintf("%s/recording?query=%s&fmt=json&inc=releases+artist-credits+tags+media+release-groups+labels", musicBrainzAPIBase, url.QueryEscape(query))
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( support@spotbye.qzz.io )", AppVersion))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < musicBrainzRequestRetries; attempt++ {
|
||||
waitForMusicBrainzRequestSlot()
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err == nil && resp != nil && resp.StatusCode == http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
|
||||
var mbResp MusicBrainzRecordingResponse
|
||||
if decodeErr := json.NewDecoder(resp.Body).Decode(&mbResp); decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
|
||||
return &mbResp, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else if resp == nil {
|
||||
lastErr = fmt.Errorf("empty response from MusicBrainz")
|
||||
} else {
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
noteMusicBrainzThrottle()
|
||||
}
|
||||
lastErr = &musicBrainzStatusError{StatusCode: resp.StatusCode}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if attempt < musicBrainzRequestRetries-1 && shouldRetryMusicBrainzRequest(lastErr) {
|
||||
time.Sleep(musicBrainzRequestRetryWait)
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("empty response from MusicBrainz")
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool, embedGenre bool) (Metadata, error) {
|
||||
var meta Metadata
|
||||
var resultErr error
|
||||
|
||||
if !embedGenre {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
if isrc == "" {
|
||||
resultErr = fmt.Errorf("no ISRC provided")
|
||||
return meta, resultErr
|
||||
}
|
||||
|
||||
cacheKey := musicBrainzCacheKey(isrc, useSingleGenre)
|
||||
if cached, ok := musicBrainzCache.Load(cacheKey); ok {
|
||||
return cached.(Metadata), nil
|
||||
}
|
||||
|
||||
if ShouldSkipMusicBrainzMetadataFetch() {
|
||||
resultErr = fmt.Errorf("skipping MusicBrainz lookup because the latest status check reported offline")
|
||||
return meta, resultErr
|
||||
}
|
||||
|
||||
musicBrainzInflightMu.Lock()
|
||||
if call, ok := musicBrainzInflight[cacheKey]; ok {
|
||||
musicBrainzInflightMu.Unlock()
|
||||
<-call.done
|
||||
return call.result, call.err
|
||||
}
|
||||
|
||||
call := &musicBrainzInflightCall{done: make(chan struct{})}
|
||||
musicBrainzInflight[cacheKey] = call
|
||||
musicBrainzInflightMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
call.result = meta
|
||||
call.err = resultErr
|
||||
|
||||
musicBrainzInflightMu.Lock()
|
||||
delete(musicBrainzInflight, cacheKey)
|
||||
close(call.done)
|
||||
musicBrainzInflightMu.Unlock()
|
||||
}()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: musicBrainzRequestTimeout,
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("isrc:%s", isrc)
|
||||
mbResp, err := queryMusicBrainzRecordings(client, query)
|
||||
if err != nil {
|
||||
resultErr = err
|
||||
return meta, resultErr
|
||||
}
|
||||
|
||||
if len(mbResp.Recordings) == 0 {
|
||||
resultErr = fmt.Errorf("no recordings found for ISRC: %s", isrc)
|
||||
return meta, resultErr
|
||||
}
|
||||
|
||||
recording := mbResp.Recordings[0]
|
||||
|
||||
var genres []string
|
||||
caser := cases.Title(language.English)
|
||||
|
||||
if useSingleGenre {
|
||||
|
||||
maxCount := -1
|
||||
var bestTag string
|
||||
|
||||
for _, tag := range recording.Tags {
|
||||
if tag.Count > maxCount {
|
||||
maxCount = tag.Count
|
||||
bestTag = tag.Name
|
||||
}
|
||||
}
|
||||
|
||||
if bestTag != "" {
|
||||
meta.Genre = caser.String(bestTag)
|
||||
}
|
||||
} else {
|
||||
for _, tag := range recording.Tags {
|
||||
|
||||
genres = append(genres, caser.String(tag.Name))
|
||||
}
|
||||
if len(genres) > 0 {
|
||||
|
||||
if len(genres) > 5 {
|
||||
genres = genres[:5]
|
||||
}
|
||||
meta.Genre = strings.Join(genres, GetSeparator())
|
||||
}
|
||||
}
|
||||
|
||||
if meta.Genre == "" {
|
||||
resultErr = fmt.Errorf("no genre tags found in MusicBrainz")
|
||||
return meta, resultErr
|
||||
}
|
||||
|
||||
musicBrainzCache.Store(cacheKey, meta)
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DownloadStatus string
|
||||
|
||||
const (
|
||||
StatusQueued DownloadStatus = "queued"
|
||||
StatusDownloading DownloadStatus = "downloading"
|
||||
StatusCompleted DownloadStatus = "completed"
|
||||
StatusFailed DownloadStatus = "failed"
|
||||
StatusSkipped DownloadStatus = "skipped"
|
||||
)
|
||||
|
||||
type DownloadItem struct {
|
||||
ID string `json:"id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Status DownloadStatus `json:"status"`
|
||||
Progress float64 `json:"progress"`
|
||||
TotalSize float64 `json:"total_size"`
|
||||
Speed float64 `json:"speed"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
EndTime int64 `json:"end_time"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
FilePath string `json:"file_path"`
|
||||
}
|
||||
|
||||
var (
|
||||
currentProgress float64
|
||||
currentProgressLock sync.RWMutex
|
||||
isDownloading bool
|
||||
downloadingLock sync.RWMutex
|
||||
currentSpeed float64
|
||||
speedLock sync.RWMutex
|
||||
|
||||
downloadQueue []DownloadItem
|
||||
downloadQueueLock sync.RWMutex
|
||||
currentItemID string
|
||||
currentItemLock sync.RWMutex
|
||||
totalDownloaded float64
|
||||
totalDownloadedLock sync.RWMutex
|
||||
sessionStartTime int64
|
||||
sessionStartLock sync.RWMutex
|
||||
)
|
||||
|
||||
type ProgressInfo struct {
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
MBDownloaded float64 `json:"mb_downloaded"`
|
||||
SpeedMBps float64 `json:"speed_mbps"`
|
||||
}
|
||||
|
||||
type DownloadQueueInfo struct {
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Queue []DownloadItem `json:"queue"`
|
||||
CurrentSpeed float64 `json:"current_speed"`
|
||||
TotalDownloaded float64 `json:"total_downloaded"`
|
||||
SessionStartTime int64 `json:"session_start_time"`
|
||||
QueuedCount int `json:"queued_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
}
|
||||
|
||||
func GetDownloadProgress() ProgressInfo {
|
||||
downloadingLock.RLock()
|
||||
downloading := isDownloading
|
||||
downloadingLock.RUnlock()
|
||||
|
||||
currentProgressLock.RLock()
|
||||
progress := currentProgress
|
||||
currentProgressLock.RUnlock()
|
||||
|
||||
speedLock.RLock()
|
||||
speed := currentSpeed
|
||||
speedLock.RUnlock()
|
||||
|
||||
return ProgressInfo{
|
||||
IsDownloading: downloading,
|
||||
MBDownloaded: progress,
|
||||
SpeedMBps: speed,
|
||||
}
|
||||
}
|
||||
|
||||
func SetDownloadSpeed(mbps float64) {
|
||||
speedLock.Lock()
|
||||
currentSpeed = mbps
|
||||
speedLock.Unlock()
|
||||
}
|
||||
|
||||
func SetDownloadProgress(mbDownloaded float64) {
|
||||
currentProgressLock.Lock()
|
||||
currentProgress = mbDownloaded
|
||||
currentProgressLock.Unlock()
|
||||
}
|
||||
|
||||
func SetDownloading(downloading bool) {
|
||||
downloadingLock.Lock()
|
||||
isDownloading = downloading
|
||||
downloadingLock.Unlock()
|
||||
|
||||
if !downloading {
|
||||
|
||||
SetDownloadProgress(0)
|
||||
SetDownloadSpeed(0)
|
||||
}
|
||||
}
|
||||
|
||||
type ProgressWriter struct {
|
||||
writer io.Writer
|
||||
total int64
|
||||
lastPrinted int64
|
||||
startTime int64
|
||||
lastTime int64
|
||||
lastBytes int64
|
||||
itemID string
|
||||
}
|
||||
|
||||
func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
||||
now := getCurrentTimeMillis()
|
||||
return &ProgressWriter{
|
||||
writer: writer,
|
||||
total: 0,
|
||||
lastPrinted: 0,
|
||||
startTime: now,
|
||||
lastTime: now,
|
||||
lastBytes: 0,
|
||||
itemID: "",
|
||||
}
|
||||
}
|
||||
|
||||
func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
|
||||
pw := NewProgressWriter(writer)
|
||||
pw.itemID = itemID
|
||||
return pw
|
||||
}
|
||||
|
||||
func getCurrentTimeMillis() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.writer.Write(p)
|
||||
pw.total += int64(n)
|
||||
|
||||
if pw.total-pw.lastPrinted >= 256*1024 {
|
||||
mbDownloaded := float64(pw.total) / (1024 * 1024)
|
||||
|
||||
now := getCurrentTimeMillis()
|
||||
timeDiff := float64(now-pw.lastTime) / 1000.0
|
||||
bytesDiff := float64(pw.total - pw.lastBytes)
|
||||
|
||||
var speedMBps float64
|
||||
if timeDiff > 0 {
|
||||
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||
SetDownloadSpeed(speedMBps)
|
||||
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
|
||||
} else {
|
||||
fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded)
|
||||
}
|
||||
|
||||
SetDownloadProgress(mbDownloaded)
|
||||
|
||||
if pw.itemID != "" {
|
||||
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
|
||||
}
|
||||
|
||||
pw.lastPrinted = pw.total
|
||||
pw.lastTime = now
|
||||
pw.lastBytes = pw.total
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (pw *ProgressWriter) GetTotal() int64 {
|
||||
return pw.total
|
||||
}
|
||||
|
||||
func AddToQueue(id, trackName, artistName, albumName, spotifyID string) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
item := DownloadItem{
|
||||
ID: id,
|
||||
TrackName: trackName,
|
||||
ArtistName: artistName,
|
||||
AlbumName: albumName,
|
||||
SpotifyID: spotifyID,
|
||||
Status: StatusQueued,
|
||||
Progress: 0,
|
||||
TotalSize: 0,
|
||||
Speed: 0,
|
||||
StartTime: 0,
|
||||
EndTime: 0,
|
||||
}
|
||||
|
||||
downloadQueue = append(downloadQueue, item)
|
||||
|
||||
sessionStartLock.Lock()
|
||||
if sessionStartTime == 0 {
|
||||
sessionStartTime = time.Now().Unix()
|
||||
}
|
||||
sessionStartLock.Unlock()
|
||||
}
|
||||
|
||||
func StartDownloadItem(id string) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
for i := range downloadQueue {
|
||||
if downloadQueue[i].ID == id {
|
||||
downloadQueue[i].Status = StatusDownloading
|
||||
downloadQueue[i].StartTime = time.Now().Unix()
|
||||
downloadQueue[i].Progress = 0
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
currentItemLock.Lock()
|
||||
currentItemID = id
|
||||
currentItemLock.Unlock()
|
||||
}
|
||||
|
||||
func UpdateItemProgress(id string, progress, speed float64) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
for i := range downloadQueue {
|
||||
if downloadQueue[i].ID == id {
|
||||
downloadQueue[i].Progress = progress
|
||||
downloadQueue[i].Speed = speed
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetCurrentItemID() string {
|
||||
currentItemLock.RLock()
|
||||
defer currentItemLock.RUnlock()
|
||||
return currentItemID
|
||||
}
|
||||
|
||||
func CompleteDownloadItem(id, filePath string, finalSize float64) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
for i := range downloadQueue {
|
||||
if downloadQueue[i].ID == id {
|
||||
downloadQueue[i].Status = StatusCompleted
|
||||
downloadQueue[i].EndTime = time.Now().Unix()
|
||||
downloadQueue[i].FilePath = filePath
|
||||
downloadQueue[i].Progress = finalSize
|
||||
downloadQueue[i].TotalSize = finalSize
|
||||
|
||||
totalDownloadedLock.Lock()
|
||||
totalDownloaded += finalSize
|
||||
totalDownloadedLock.Unlock()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FailDownloadItem(id, errorMsg string) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
for i := range downloadQueue {
|
||||
if downloadQueue[i].ID == id {
|
||||
downloadQueue[i].Status = StatusFailed
|
||||
downloadQueue[i].EndTime = time.Now().Unix()
|
||||
downloadQueue[i].ErrorMessage = errorMsg
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SkipDownloadItem(id, filePath string) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
for i := range downloadQueue {
|
||||
if downloadQueue[i].ID == id {
|
||||
downloadQueue[i].Status = StatusSkipped
|
||||
downloadQueue[i].EndTime = time.Now().Unix()
|
||||
downloadQueue[i].FilePath = filePath
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetDownloadQueue() DownloadQueueInfo {
|
||||
|
||||
ResetSessionIfComplete()
|
||||
|
||||
downloadQueueLock.RLock()
|
||||
defer downloadQueueLock.RUnlock()
|
||||
|
||||
downloadingLock.RLock()
|
||||
downloading := isDownloading
|
||||
downloadingLock.RUnlock()
|
||||
|
||||
speedLock.RLock()
|
||||
speed := currentSpeed
|
||||
speedLock.RUnlock()
|
||||
|
||||
totalDownloadedLock.RLock()
|
||||
total := totalDownloaded
|
||||
totalDownloadedLock.RUnlock()
|
||||
|
||||
sessionStartLock.RLock()
|
||||
sessionStart := sessionStartTime
|
||||
sessionStartLock.RUnlock()
|
||||
|
||||
var queued, completed, failed, skipped int
|
||||
for _, item := range downloadQueue {
|
||||
switch item.Status {
|
||||
case StatusQueued:
|
||||
queued++
|
||||
case StatusCompleted:
|
||||
completed++
|
||||
case StatusFailed:
|
||||
failed++
|
||||
case StatusSkipped:
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
||||
queueCopy := make([]DownloadItem, len(downloadQueue))
|
||||
copy(queueCopy, downloadQueue)
|
||||
|
||||
return DownloadQueueInfo{
|
||||
IsDownloading: downloading,
|
||||
Queue: queueCopy,
|
||||
CurrentSpeed: speed,
|
||||
TotalDownloaded: total,
|
||||
SessionStartTime: sessionStart,
|
||||
QueuedCount: queued,
|
||||
CompletedCount: completed,
|
||||
FailedCount: failed,
|
||||
SkippedCount: skipped,
|
||||
}
|
||||
}
|
||||
|
||||
func ClearDownloadQueue() {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
newQueue := make([]DownloadItem, 0)
|
||||
for _, item := range downloadQueue {
|
||||
if item.Status == StatusQueued || item.Status == StatusDownloading {
|
||||
newQueue = append(newQueue, item)
|
||||
}
|
||||
}
|
||||
downloadQueue = newQueue
|
||||
}
|
||||
|
||||
func ClearAllDownloads() {
|
||||
downloadQueueLock.Lock()
|
||||
downloadQueue = []DownloadItem{}
|
||||
downloadQueueLock.Unlock()
|
||||
|
||||
totalDownloadedLock.Lock()
|
||||
totalDownloaded = 0
|
||||
totalDownloadedLock.Unlock()
|
||||
|
||||
sessionStartLock.Lock()
|
||||
sessionStartTime = 0
|
||||
sessionStartLock.Unlock()
|
||||
|
||||
currentItemLock.Lock()
|
||||
currentItemID = ""
|
||||
currentItemLock.Unlock()
|
||||
|
||||
SetDownloadProgress(0)
|
||||
SetDownloadSpeed(0)
|
||||
}
|
||||
|
||||
func CancelAllQueuedItems() {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
for i := range downloadQueue {
|
||||
if downloadQueue[i].Status == StatusQueued {
|
||||
downloadQueue[i].Status = StatusSkipped
|
||||
downloadQueue[i].EndTime = time.Now().Unix()
|
||||
downloadQueue[i].ErrorMessage = "Cancelled"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ResetSessionIfComplete() {
|
||||
downloadQueueLock.RLock()
|
||||
hasActiveOrQueued := false
|
||||
for _, item := range downloadQueue {
|
||||
if item.Status == StatusQueued || item.Status == StatusDownloading {
|
||||
hasActiveOrQueued = true
|
||||
break
|
||||
}
|
||||
}
|
||||
downloadQueueLock.RUnlock()
|
||||
|
||||
if !hasActiveOrQueued {
|
||||
sessionStartLock.Lock()
|
||||
sessionStartTime = 0
|
||||
sessionStartLock.Unlock()
|
||||
|
||||
totalDownloadedLock.Lock()
|
||||
totalDownloaded = 0
|
||||
totalDownloadedLock.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
|
||||
|
||||
const (
|
||||
qobuzWJHEBaseURL = "https://music.wjhe.top"
|
||||
qobuzWJHESearchAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/search"
|
||||
qobuzWJHEStreamAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/url"
|
||||
qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
||||
qobuzGDStudioAPIURLXYZ = "https://music.gdstudio.xyz/api.php"
|
||||
qobuzGDStudioAPIURLORG = "https://music.gdstudio.org/api.php"
|
||||
qobuzGDStudioVersion = "2026.5.10"
|
||||
)
|
||||
|
||||
var defaultQobuzDownloadProviderURLs = []string{
|
||||
qobuzWJHEStreamAPIURL,
|
||||
qobuzGDStudioAPIURLXYZ,
|
||||
qobuzGDStudioAPIURLORG,
|
||||
qobuzMusicDLDownloadAPIURL,
|
||||
}
|
||||
|
||||
func GetQobuzDownloadProviderURLs() []string {
|
||||
return append([]string(nil), defaultQobuzDownloadProviderURLs...)
|
||||
}
|
||||
|
||||
func GetQobuzWJHESearchAPIURL() string {
|
||||
return qobuzWJHESearchAPIURL
|
||||
}
|
||||
|
||||
func GetQobuzWJHEStreamAPIURL() string {
|
||||
return qobuzWJHEStreamAPIURL
|
||||
}
|
||||
|
||||
func GetQobuzMusicDLDownloadAPIURL() string {
|
||||
return qobuzMusicDLDownloadAPIURL
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioAPIURLs() []string {
|
||||
return []string{qobuzGDStudioAPIURLXYZ, qobuzGDStudioAPIURLORG}
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioPrimaryAPIURL() string {
|
||||
return qobuzGDStudioAPIURLXYZ
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioFallbackAPIURL() string {
|
||||
return qobuzGDStudioAPIURLORG
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioSignatureHost(apiURL string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(apiURL))
|
||||
if err != nil || strings.TrimSpace(parsed.Host) == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parsed.Host)
|
||||
}
|
||||
|
||||
func GetQobuzGDStudioVersion() string {
|
||||
return qobuzGDStudioVersion
|
||||
}
|
||||
|
||||
func IsQobuzWJHEProviderURL(raw string) bool {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
return candidate == qobuzWJHEStreamAPIURL || strings.HasPrefix(candidate, qobuzWJHEStreamAPIURL+"?")
|
||||
}
|
||||
|
||||
func IsQobuzMusicDLProviderURL(raw string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(raw), qobuzMusicDLDownloadAPIURL)
|
||||
}
|
||||
|
||||
func IsQobuzGDStudioProviderURL(raw string) bool {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
|
||||
if candidate == apiURL || strings.HasPrefix(candidate, apiURL+"?") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetAmazonMusicAPIBaseURL() string {
|
||||
return amazonMusicAPIBaseURL
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
const (
|
||||
providerPriorityDBFile = "provider_priority.db"
|
||||
providerPriorityBucket = "ProviderPriority"
|
||||
)
|
||||
|
||||
type providerPriorityEntry struct {
|
||||
Service string `json:"service"`
|
||||
Provider string `json:"provider"`
|
||||
LastOutcome string `json:"last_outcome"`
|
||||
LastAttempt int64 `json:"last_attempt"`
|
||||
LastSuccess int64 `json:"last_success"`
|
||||
LastFailure int64 `json:"last_failure"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
FailureCount int64 `json:"failure_count"`
|
||||
}
|
||||
|
||||
var (
|
||||
providerPriorityDB *bolt.DB
|
||||
providerPriorityDBMu sync.Mutex
|
||||
)
|
||||
|
||||
func InitProviderPriorityDB() error {
|
||||
providerPriorityDBMu.Lock()
|
||||
defer providerPriorityDBMu.Unlock()
|
||||
|
||||
if providerPriorityDB != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(appDir, providerPriorityDBFile)
|
||||
db, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Update(func(tx *bolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists([]byte(providerPriorityBucket))
|
||||
return err
|
||||
}); err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
providerPriorityDB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloseProviderPriorityDB() {
|
||||
providerPriorityDBMu.Lock()
|
||||
defer providerPriorityDBMu.Unlock()
|
||||
|
||||
if providerPriorityDB != nil {
|
||||
_ = providerPriorityDB.Close()
|
||||
providerPriorityDB = nil
|
||||
}
|
||||
}
|
||||
|
||||
func prioritizeProviders(service string, providers []string) []string {
|
||||
ordered := append([]string(nil), providers...)
|
||||
if len(ordered) < 2 {
|
||||
return ordered
|
||||
}
|
||||
|
||||
if err := InitProviderPriorityDB(); err != nil {
|
||||
fmt.Printf("Warning: failed to init provider priority DB: %v\n", err)
|
||||
return ordered
|
||||
}
|
||||
|
||||
serviceKey := strings.TrimSpace(strings.ToLower(service))
|
||||
entries := make(map[string]providerPriorityEntry, len(ordered))
|
||||
|
||||
if err := providerPriorityDB.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(providerPriorityBucket))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, provider := range ordered {
|
||||
if raw := bucket.Get([]byte(providerPriorityKey(serviceKey, provider))); len(raw) > 0 {
|
||||
var entry providerPriorityEntry
|
||||
if err := json.Unmarshal(raw, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
entries[provider] = entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
fmt.Printf("Warning: failed to read provider priority DB: %v\n", err)
|
||||
return ordered
|
||||
}
|
||||
|
||||
originalIndex := make(map[string]int, len(ordered))
|
||||
for idx, provider := range ordered {
|
||||
originalIndex[provider] = idx
|
||||
}
|
||||
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
left := entries[ordered[i]]
|
||||
right := entries[ordered[j]]
|
||||
|
||||
leftRank := providerOutcomeRank(left.LastOutcome)
|
||||
rightRank := providerOutcomeRank(right.LastOutcome)
|
||||
if leftRank != rightRank {
|
||||
return leftRank > rightRank
|
||||
}
|
||||
|
||||
if left.LastSuccess != right.LastSuccess {
|
||||
return left.LastSuccess > right.LastSuccess
|
||||
}
|
||||
|
||||
if left.LastAttempt != right.LastAttempt {
|
||||
return left.LastAttempt > right.LastAttempt
|
||||
}
|
||||
|
||||
return originalIndex[ordered[i]] < originalIndex[ordered[j]]
|
||||
})
|
||||
|
||||
return ordered
|
||||
}
|
||||
|
||||
func recordProviderSuccess(service string, provider string) {
|
||||
recordProviderOutcome(service, provider, true)
|
||||
}
|
||||
|
||||
func recordProviderFailure(service string, provider string) {
|
||||
recordProviderOutcome(service, provider, false)
|
||||
}
|
||||
|
||||
func recordProviderOutcome(service string, provider string, success bool) {
|
||||
if strings.TrimSpace(service) == "" || strings.TrimSpace(provider) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := InitProviderPriorityDB(); err != nil {
|
||||
fmt.Printf("Warning: failed to init provider priority DB: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceKey := strings.TrimSpace(strings.ToLower(service))
|
||||
providerKey := providerPriorityKey(serviceKey, provider)
|
||||
now := time.Now().Unix()
|
||||
|
||||
if err := providerPriorityDB.Update(func(tx *bolt.Tx) error {
|
||||
bucket, err := tx.CreateBucketIfNotExists([]byte(providerPriorityBucket))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := providerPriorityEntry{
|
||||
Service: serviceKey,
|
||||
Provider: provider,
|
||||
}
|
||||
|
||||
if raw := bucket.Get([]byte(providerKey)); len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
entry.LastAttempt = now
|
||||
if success {
|
||||
entry.LastOutcome = "success"
|
||||
entry.LastSuccess = now
|
||||
entry.SuccessCount++
|
||||
} else {
|
||||
entry.LastOutcome = "failure"
|
||||
entry.LastFailure = now
|
||||
entry.FailureCount++
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return bucket.Put([]byte(providerKey), payload)
|
||||
}); err != nil {
|
||||
fmt.Printf("Warning: failed to update provider priority DB: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func providerOutcomeRank(outcome string) int {
|
||||
switch strings.TrimSpace(strings.ToLower(outcome)) {
|
||||
case "success":
|
||||
return 2
|
||||
case "":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func providerPriorityKey(service string, provider string) string {
|
||||
return strings.TrimSpace(strings.ToLower(service)) + "|" + strings.TrimSpace(provider)
|
||||
}
|
||||
-1127
File diff suppressed because it is too large
Load Diff
@@ -1,407 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
qobuzAPIBaseURL = "https://www.qobuz.com/api.json/0.2"
|
||||
qobuzDefaultAPIAppID = "712109809"
|
||||
qobuzDefaultAPIAppSecret = "589be88e4538daea11f509d29e4a23b1"
|
||||
qobuzDefaultUA = DefaultDownloaderUserAgent
|
||||
qobuzCredentialsCacheFile = "qobuz-api-credentials.json"
|
||||
qobuzCredentialsCacheTTL = 24 * time.Hour
|
||||
qobuzCredentialsProbeTrackISRC = "USUM71703861"
|
||||
qobuzOpenTrackProbeURL = "https://open.qobuz.com/track/1"
|
||||
)
|
||||
|
||||
var (
|
||||
qobuzCredentialsMu sync.Mutex
|
||||
qobuzCachedCredentials *qobuzAPICredentials
|
||||
qobuzOpenBundleScriptPattern = regexp.MustCompile(`<script[^>]+src="([^"]+/js/main\.js|/resources/[^"]+/js/main\.js)"`)
|
||||
qobuzOpenAPIConfigPattern = regexp.MustCompile(`app_id:"(?P<app_id>\d{9})",app_secret:"(?P<app_secret>[a-f0-9]{32})"`)
|
||||
)
|
||||
|
||||
type qobuzAPICredentials struct {
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
Source string `json:"source,omitempty"`
|
||||
FetchedAtUnix int64 `json:"fetched_at_unix"`
|
||||
}
|
||||
|
||||
type qobuzCredentialProbeResponse struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
func defaultQobuzAPICredentials() *qobuzAPICredentials {
|
||||
return &qobuzAPICredentials{
|
||||
AppID: qobuzDefaultAPIAppID,
|
||||
AppSecret: qobuzDefaultAPIAppSecret,
|
||||
Source: "embedded-default",
|
||||
FetchedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func qobuzCredentialsCachePath() (string, error) {
|
||||
appDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(appDir, qobuzCredentialsCacheFile), nil
|
||||
}
|
||||
|
||||
func loadQobuzCachedCredentials() (*qobuzAPICredentials, error) {
|
||||
cachePath, err := qobuzCredentialsCachePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read qobuz credentials cache: %w", err)
|
||||
}
|
||||
|
||||
var creds qobuzAPICredentials
|
||||
if err := json.Unmarshal(body, &creds); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse qobuz credentials cache: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" {
|
||||
return nil, fmt.Errorf("qobuz credentials cache is incomplete")
|
||||
}
|
||||
|
||||
return &creds, nil
|
||||
}
|
||||
|
||||
func saveQobuzCachedCredentials(creds *qobuzAPICredentials) error {
|
||||
if creds == nil {
|
||||
return fmt.Errorf("qobuz credentials are required")
|
||||
}
|
||||
|
||||
cachePath, err := qobuzCredentialsCachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create qobuz credentials cache directory: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.MarshalIndent(creds, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cachePath, body, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write qobuz credentials cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func qobuzCredentialsCacheIsFresh(creds *qobuzAPICredentials) bool {
|
||||
if creds == nil || creds.FetchedAtUnix == 0 || strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" {
|
||||
return false
|
||||
}
|
||||
return time.Since(time.Unix(creds.FetchedAtUnix, 0)) < qobuzCredentialsCacheTTL
|
||||
}
|
||||
|
||||
func scrapeQobuzOpenCredentials(client *http.Client) (*qobuzAPICredentials, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, qobuzOpenTrackProbeURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", qobuzDefaultUA)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch open.qobuz.com shell: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
preview, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("open.qobuz.com returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(preview)))
|
||||
}
|
||||
|
||||
htmlBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read open.qobuz.com shell: %w", err)
|
||||
}
|
||||
|
||||
scriptMatch := qobuzOpenBundleScriptPattern.FindStringSubmatch(string(htmlBody))
|
||||
if len(scriptMatch) < 2 {
|
||||
return nil, fmt.Errorf("qobuz open bundle URL not found")
|
||||
}
|
||||
|
||||
bundleURL := strings.TrimSpace(scriptMatch[1])
|
||||
if strings.HasPrefix(bundleURL, "/") {
|
||||
bundleURL = "https://open.qobuz.com" + bundleURL
|
||||
}
|
||||
if bundleURL == "" {
|
||||
return nil, fmt.Errorf("qobuz open bundle URL is empty")
|
||||
}
|
||||
|
||||
bundleReq, err := http.NewRequest(http.MethodGet, bundleURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundleReq.Header.Set("User-Agent", qobuzDefaultUA)
|
||||
|
||||
bundleResp, err := client.Do(bundleReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch qobuz open bundle: %w", err)
|
||||
}
|
||||
defer bundleResp.Body.Close()
|
||||
|
||||
if bundleResp.StatusCode != http.StatusOK {
|
||||
preview, _ := io.ReadAll(io.LimitReader(bundleResp.Body, 512))
|
||||
return nil, fmt.Errorf("qobuz open bundle returned status %d: %s", bundleResp.StatusCode, strings.TrimSpace(string(preview)))
|
||||
}
|
||||
|
||||
bundleBody, err := io.ReadAll(bundleResp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read qobuz open bundle: %w", err)
|
||||
}
|
||||
|
||||
configMatch := qobuzOpenAPIConfigPattern.FindStringSubmatch(string(bundleBody))
|
||||
if len(configMatch) < 3 {
|
||||
return nil, fmt.Errorf("qobuz api app_id/app_secret pair not found in open bundle")
|
||||
}
|
||||
|
||||
return &qobuzAPICredentials{
|
||||
AppID: strings.TrimSpace(configMatch[1]),
|
||||
AppSecret: strings.TrimSpace(configMatch[2]),
|
||||
Source: bundleURL,
|
||||
FetchedAtUnix: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func qobuzNormalizedPath(path string) string {
|
||||
return strings.Trim(strings.TrimSpace(path), "/")
|
||||
}
|
||||
|
||||
func qobuzSignaturePayload(path string, params url.Values, timestamp string, secret string) string {
|
||||
normalizedPath := strings.ReplaceAll(qobuzNormalizedPath(path), "/", "")
|
||||
keys := make([]string, 0, len(params))
|
||||
for key := range params {
|
||||
switch key {
|
||||
case "app_id", "request_ts", "request_sig":
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString(normalizedPath)
|
||||
for _, key := range keys {
|
||||
values := params[key]
|
||||
if len(values) == 0 {
|
||||
builder.WriteString(key)
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
builder.WriteString(key)
|
||||
builder.WriteString(value)
|
||||
}
|
||||
}
|
||||
builder.WriteString(timestamp)
|
||||
builder.WriteString(secret)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func qobuzRequestSignature(path string, params url.Values, timestamp string, secret string) string {
|
||||
sum := md5.Sum([]byte(qobuzSignaturePayload(path, params, timestamp, secret)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func newQobuzSignedRequestWithCredentials(method string, path string, params url.Values, creds *qobuzAPICredentials) (*http.Request, error) {
|
||||
normalizedPath := qobuzNormalizedPath(path)
|
||||
if normalizedPath == "" {
|
||||
return nil, fmt.Errorf("qobuz request path is empty")
|
||||
}
|
||||
if creds == nil || strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" {
|
||||
return nil, fmt.Errorf("qobuz credentials are incomplete")
|
||||
}
|
||||
|
||||
clonedParams := url.Values{}
|
||||
for key, values := range params {
|
||||
for _, value := range values {
|
||||
clonedParams.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
timestamp := fmt.Sprintf("%d", time.Now().Unix())
|
||||
clonedParams.Set("app_id", creds.AppID)
|
||||
clonedParams.Set("request_ts", timestamp)
|
||||
clonedParams.Set("request_sig", qobuzRequestSignature(normalizedPath, params, timestamp, creds.AppSecret))
|
||||
|
||||
reqURL := fmt.Sprintf("%s/%s?%s", qobuzAPIBaseURL, normalizedPath, clonedParams.Encode())
|
||||
req, err := http.NewRequest(method, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", qobuzDefaultUA)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-App-Id", creds.AppID)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func qobuzCredentialsSupportSignedMetadata(client *http.Client, creds *qobuzAPICredentials) bool {
|
||||
if creds == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
req, err := newQobuzSignedRequestWithCredentials(http.MethodGet, "track/search", url.Values{
|
||||
"query": {qobuzCredentialsProbeTrackISRC},
|
||||
"limit": {"1"},
|
||||
}, creds)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
var payload qobuzCredentialProbeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return payload.Tracks.Total > 0
|
||||
}
|
||||
|
||||
func getQobuzAPICredentials(forceRefresh bool) (*qobuzAPICredentials, error) {
|
||||
qobuzCredentialsMu.Lock()
|
||||
defer qobuzCredentialsMu.Unlock()
|
||||
|
||||
if !forceRefresh && qobuzCredentialsCacheIsFresh(qobuzCachedCredentials) {
|
||||
return qobuzCachedCredentials, nil
|
||||
}
|
||||
|
||||
cachedFromDisk, diskErr := loadQobuzCachedCredentials()
|
||||
if diskErr != nil {
|
||||
fmt.Printf("Warning: failed to read Qobuz credentials cache: %v\n", diskErr)
|
||||
}
|
||||
if !forceRefresh && qobuzCredentialsCacheIsFresh(cachedFromDisk) {
|
||||
qobuzCachedCredentials = cachedFromDisk
|
||||
return qobuzCachedCredentials, nil
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
scrapedCreds, scrapeErr := scrapeQobuzOpenCredentials(client)
|
||||
if scrapeErr == nil {
|
||||
if qobuzCredentialsSupportSignedMetadata(client, scrapedCreds) {
|
||||
qobuzCachedCredentials = scrapedCreds
|
||||
if err := saveQobuzCachedCredentials(scrapedCreds); err != nil {
|
||||
fmt.Printf("Warning: failed to write Qobuz credentials cache: %v\n", err)
|
||||
}
|
||||
fmt.Printf("Loaded fresh Qobuz credentials from %s (app_id=%s)\n", scrapedCreds.Source, scrapedCreds.AppID)
|
||||
return qobuzCachedCredentials, nil
|
||||
}
|
||||
scrapeErr = fmt.Errorf("scraped qobuz credentials did not pass validation")
|
||||
}
|
||||
|
||||
if cachedFromDisk != nil {
|
||||
qobuzCachedCredentials = cachedFromDisk
|
||||
fmt.Printf("Warning: failed to refresh Qobuz credentials, using cached credentials: %v\n", scrapeErr)
|
||||
return qobuzCachedCredentials, nil
|
||||
}
|
||||
|
||||
if qobuzCachedCredentials != nil {
|
||||
fmt.Printf("Warning: failed to refresh Qobuz credentials, using in-memory credentials: %v\n", scrapeErr)
|
||||
return qobuzCachedCredentials, nil
|
||||
}
|
||||
|
||||
fallback := defaultQobuzAPICredentials()
|
||||
qobuzCachedCredentials = fallback
|
||||
if scrapeErr != nil {
|
||||
fmt.Printf("Warning: failed to refresh Qobuz credentials, using embedded fallback: %v\n", scrapeErr)
|
||||
}
|
||||
return qobuzCachedCredentials, nil
|
||||
}
|
||||
|
||||
func qobuzShouldRefreshCredentials(statusCode int) bool {
|
||||
return statusCode == http.StatusBadRequest || statusCode == http.StatusUnauthorized
|
||||
}
|
||||
|
||||
func newQobuzSignedRequest(method string, path string, params url.Values) (*http.Request, error) {
|
||||
creds, err := getQobuzAPICredentials(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newQobuzSignedRequestWithCredentials(method, path, params, creds)
|
||||
}
|
||||
|
||||
func doQobuzSignedRequest(method string, path string, params url.Values, client *http.Client) (*http.Response, error) {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
|
||||
call := func(forceRefresh bool) (*http.Response, error) {
|
||||
creds, err := getQobuzAPICredentials(forceRefresh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := newQobuzSignedRequestWithCredentials(method, path, params, creds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
resp, err := call(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if qobuzShouldRefreshCredentials(resp.StatusCode) {
|
||||
resp.Body.Close()
|
||||
return call(true)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func doQobuzSignedJSONRequest(path string, params url.Values, target interface{}) error {
|
||||
resp, err := doQobuzSignedRequest(http.MethodGet, path, params, &http.Client{Timeout: 20 * time.Second})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("qobuz request failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package backend
|
||||
|
||||
type qobuzDownloadProvider interface {
|
||||
Name() string
|
||||
Attempts(trackID int64, quality string) []qobuzProviderAttempt
|
||||
}
|
||||
|
||||
type qobuzProviderAttempt struct {
|
||||
Name string
|
||||
ID string
|
||||
Download func() (string, error)
|
||||
}
|
||||
|
||||
type QobuzProviderWJHE struct {
|
||||
downloader *QobuzDownloader
|
||||
}
|
||||
|
||||
func (p QobuzProviderWJHE) Name() string {
|
||||
return "QobuzProviderWJHE"
|
||||
}
|
||||
|
||||
func (p QobuzProviderWJHE) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||
return []qobuzProviderAttempt{
|
||||
{
|
||||
Name: p.Name(),
|
||||
ID: GetQobuzWJHEStreamAPIURL(),
|
||||
Download: func() (string, error) {
|
||||
return p.downloader.DownloadFromWJHE(trackID, quality)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type QobuzProviderMusicDL struct {
|
||||
downloader *QobuzDownloader
|
||||
}
|
||||
|
||||
func (p QobuzProviderMusicDL) Name() string {
|
||||
return "QobuzProviderMusicDL"
|
||||
}
|
||||
|
||||
func (p QobuzProviderMusicDL) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||
return []qobuzProviderAttempt{
|
||||
{
|
||||
Name: p.Name(),
|
||||
ID: GetQobuzMusicDLDownloadAPIURL(),
|
||||
Download: func() (string, error) {
|
||||
return p.downloader.DownloadFromMusicDL(trackID, quality)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type QobuzProviderGDStudio struct {
|
||||
downloader *QobuzDownloader
|
||||
}
|
||||
|
||||
func (p QobuzProviderGDStudio) Name() string {
|
||||
return "QobuzProviderGDStudio"
|
||||
}
|
||||
|
||||
func (p QobuzProviderGDStudio) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||
attempts := make([]qobuzProviderAttempt, 0, len(GetQobuzGDStudioAPIURLs()))
|
||||
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
|
||||
currentAPIURL := apiURL
|
||||
attempts = append(attempts, qobuzProviderAttempt{
|
||||
Name: p.Name(),
|
||||
ID: currentAPIURL,
|
||||
Download: func() (string, error) {
|
||||
return p.downloader.DownloadFromGDStudio(trackID, quality, currentAPIURL)
|
||||
},
|
||||
})
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getQobuzDownloadProviders() []qobuzDownloadProvider {
|
||||
return []qobuzDownloadProvider{
|
||||
QobuzProviderWJHE{downloader: q},
|
||||
QobuzProviderGDStudio{downloader: q},
|
||||
QobuzProviderMusicDL{downloader: q},
|
||||
}
|
||||
}
|
||||
|
||||
func moveQobuzAttemptIDsLast(providerIDs []string, lastIDs ...string) []string {
|
||||
if len(providerIDs) == 0 || len(lastIDs) == 0 {
|
||||
return append([]string(nil), providerIDs...)
|
||||
}
|
||||
|
||||
lastIDSet := make(map[string]struct{}, len(lastIDs))
|
||||
for _, providerID := range lastIDs {
|
||||
lastIDSet[providerID] = struct{}{}
|
||||
}
|
||||
|
||||
ordered := make([]string, 0, len(providerIDs))
|
||||
trailing := make([]string, 0, len(providerIDs))
|
||||
for _, providerID := range providerIDs {
|
||||
if _, ok := lastIDSet[providerID]; ok {
|
||||
trailing = append(trailing, providerID)
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, providerID)
|
||||
}
|
||||
|
||||
return append(ordered, trailing...)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
var (
|
||||
recentFetchesMu sync.Mutex
|
||||
recentFetchesDirResolver = GetFFmpegDir
|
||||
)
|
||||
|
||||
func recentFetchesFilePath() (string, error) {
|
||||
baseDir, err := recentFetchesDirResolver()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(baseDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(baseDir, recentFetchesFileName), nil
|
||||
}
|
||||
|
||||
func LoadRecentFetches() ([]RecentFetchItem, error) {
|
||||
recentFetchesMu.Lock()
|
||||
defer recentFetchesMu.Unlock()
|
||||
|
||||
filePath, err := recentFetchesFilePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []RecentFetchItem{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return []RecentFetchItem{}, nil
|
||||
}
|
||||
|
||||
var items []RecentFetchItem
|
||||
if err := json.Unmarshal(data, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if items == nil {
|
||||
return []RecentFetchItem{}, nil
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func SaveRecentFetches(items []RecentFetchItem) error {
|
||||
recentFetchesMu.Lock()
|
||||
defer recentFetchesMu.Unlock()
|
||||
|
||||
filePath, err := recentFetchesFilePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if items == nil {
|
||||
items = []RecentFetchItem{}
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(items, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filePath, data, 0o644)
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type FlacInfo struct {
|
||||
Path string `json:"path"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
}
|
||||
|
||||
func GetFlacInfoBatch(paths []string) []FlacInfo {
|
||||
results := make([]FlacInfo, len(paths))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, path := range paths {
|
||||
wg.Add(1)
|
||||
go func(idx int, p string) {
|
||||
defer wg.Done()
|
||||
info := FlacInfo{Path: p}
|
||||
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
results[idx] = info
|
||||
return
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate,bits_per_raw_sample,bits_per_sample",
|
||||
"-of", "default=noprint_wrappers=0",
|
||||
p,
|
||||
}
|
||||
cmd := exec.Command(ffprobePath, args...)
|
||||
setHideWindow(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
results[idx] = info
|
||||
return
|
||||
}
|
||||
|
||||
kvMap := make(map[string]string)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if parts := strings.SplitN(line, "=", 2); len(parts) == 2 {
|
||||
kvMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := kvMap["sample_rate"]; ok {
|
||||
if s, err := strconv.Atoi(v); err == nil {
|
||||
info.SampleRate = uint32(s)
|
||||
}
|
||||
}
|
||||
|
||||
bits := 0
|
||||
if v, ok := kvMap["bits_per_raw_sample"]; ok && v != "N/A" && v != "" {
|
||||
bits, _ = strconv.Atoi(v)
|
||||
}
|
||||
if bits == 0 {
|
||||
if v, ok := kvMap["bits_per_sample"]; ok && v != "N/A" && v != "" {
|
||||
bits, _ = strconv.Atoi(v)
|
||||
}
|
||||
}
|
||||
info.BitsPerSample = uint8(bits)
|
||||
|
||||
results[idx] = info
|
||||
}(i, path)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
type ResampleRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
}
|
||||
|
||||
type ResampleResult struct {
|
||||
InputFile string `json:"input_file"`
|
||||
OutputFile string `json:"output_file"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func buildFolderLabel(sampleRate, bitDepth string) string {
|
||||
var parts []string
|
||||
|
||||
if bitDepth != "" {
|
||||
parts = append(parts, bitDepth+"bit")
|
||||
}
|
||||
|
||||
switch sampleRate {
|
||||
case "44100":
|
||||
parts = append(parts, "44.1kHz")
|
||||
case "48000":
|
||||
parts = append(parts, "48kHz")
|
||||
case "96000":
|
||||
parts = append(parts, "96kHz")
|
||||
case "192000":
|
||||
parts = append(parts, "192kHz")
|
||||
default:
|
||||
if sampleRate != "" {
|
||||
parts = append(parts, sampleRate+"Hz")
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "Resampled"
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func ResampleAudio(req ResampleRequest) ([]ResampleResult, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||
return nil, fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
installed, err := IsFFmpegInstalled()
|
||||
if err != nil || !installed {
|
||||
return nil, fmt.Errorf("ffmpeg is not installed")
|
||||
}
|
||||
|
||||
if req.SampleRate == "" && req.BitDepth == "" {
|
||||
return nil, fmt.Errorf("at least one of sample rate or bit depth must be specified")
|
||||
}
|
||||
|
||||
results := make([]ResampleResult, len(req.InputFiles))
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
folderLabel := buildFolderLabel(req.SampleRate, req.BitDepth)
|
||||
|
||||
for i, inputFile := range req.InputFiles {
|
||||
wg.Add(1)
|
||||
go func(idx int, inputFile string) {
|
||||
defer wg.Done()
|
||||
|
||||
result := ResampleResult{
|
||||
InputFile: inputFile,
|
||||
}
|
||||
|
||||
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
||||
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
||||
inputDir := filepath.Dir(inputFile)
|
||||
|
||||
outputDir := filepath.Join(inputDir, folderLabel)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
outputFile := filepath.Join(outputDir, baseName+".flac")
|
||||
result.OutputFile = outputFile
|
||||
|
||||
args := []string{
|
||||
"-i", inputFile,
|
||||
"-y",
|
||||
}
|
||||
|
||||
if req.BitDepth != "" {
|
||||
switch req.BitDepth {
|
||||
case "16":
|
||||
args = append(args, "-c:a", "flac", "-sample_fmt", "s16")
|
||||
case "24":
|
||||
args = append(args, "-c:a", "flac", "-sample_fmt", "s32", "-bits_per_raw_sample", "24")
|
||||
default:
|
||||
args = append(args, "-c:a", "flac")
|
||||
}
|
||||
} else {
|
||||
args = append(args, "-c:a", "flac")
|
||||
}
|
||||
|
||||
if req.SampleRate != "" {
|
||||
args = append(args, "-ar", req.SampleRate)
|
||||
}
|
||||
|
||||
args = append(args, "-map_metadata", "0")
|
||||
args = append(args, outputFile)
|
||||
|
||||
fmt.Printf("[Resample] %s -> %s\n", inputFile, outputFile)
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("resampling failed: %s - %s", err.Error(), string(output))
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
fmt.Printf("[Resample] Done: %s\n", outputFile)
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
}(i, inputFile)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results, nil
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const songLinkUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
|
||||
|
||||
var (
|
||||
isrcPattern = regexp.MustCompile(`\b([A-Z]{2}[A-Z0-9]{3}\d{7})\b`)
|
||||
amazonAlbumTrackPath = regexp.MustCompile(`/albums/[A-Z0-9]{10}/(B[0-9A-Z]{9})`)
|
||||
amazonTrackPath = regexp.MustCompile(`/tracks/(B[0-9A-Z]{9})`)
|
||||
)
|
||||
|
||||
type SongLinkClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type SongLinkURLs struct {
|
||||
TidalURL string `json:"tidal_url"`
|
||||
AmazonURL string `json:"amazon_url"`
|
||||
ISRC string `json:"isrc"`
|
||||
}
|
||||
|
||||
type TrackAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
Amazon bool `json:"amazon"`
|
||||
Qobuz bool `json:"qobuz"`
|
||||
Deezer bool `json:"deezer"`
|
||||
TidalURL string `json:"tidal_url,omitempty"`
|
||||
AmazonURL string `json:"amazon_url,omitempty"`
|
||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||
DeezerURL string `json:"deezer_url,omitempty"`
|
||||
}
|
||||
|
||||
type songLinkAPIResponse struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
type qobuzAvailabilityTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Album struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
RelativeURL string `json:"relative_url"`
|
||||
} `json:"album"`
|
||||
}
|
||||
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
return &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region string) (*SongLinkURLs, error) {
|
||||
links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, region)
|
||||
if err != nil && (links == nil || (links.TidalURL == "" && links.AmazonURL == "")) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
urls := &SongLinkURLs{}
|
||||
if links != nil {
|
||||
urls.TidalURL = links.TidalURL
|
||||
urls.AmazonURL = normalizeAmazonMusicURL(links.AmazonURL)
|
||||
urls.ISRC = links.ISRC
|
||||
}
|
||||
|
||||
if urls.TidalURL == "" && urls.AmazonURL == "" {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("no streaming URLs found")
|
||||
}
|
||||
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, "")
|
||||
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
}
|
||||
|
||||
if links != nil {
|
||||
availability.TidalURL = links.TidalURL
|
||||
availability.AmazonURL = normalizeAmazonMusicURL(links.AmazonURL)
|
||||
availability.DeezerURL = normalizeDeezerTrackURL(links.DeezerURL)
|
||||
availability.Tidal = availability.TidalURL != ""
|
||||
availability.Amazon = availability.AmazonURL != ""
|
||||
availability.Deezer = availability.DeezerURL != ""
|
||||
}
|
||||
|
||||
isrc := ""
|
||||
if links != nil {
|
||||
isrc = strings.TrimSpace(links.ISRC)
|
||||
}
|
||||
|
||||
if isrc == "" && availability.DeezerURL != "" {
|
||||
if resolvedISRC, deezerErr := getDeezerISRC(availability.DeezerURL); deezerErr == nil {
|
||||
isrc = resolvedISRC
|
||||
}
|
||||
}
|
||||
|
||||
if isrc == "" {
|
||||
if fallbackISRC, fallbackErr := s.lookupSpotifyISRC(spotifyTrackID); fallbackErr == nil {
|
||||
isrc = fallbackISRC
|
||||
} else if err == nil {
|
||||
err = fallbackErr
|
||||
}
|
||||
}
|
||||
|
||||
if isrc != "" {
|
||||
availability.Qobuz, availability.QobuzURL = checkQobuzAvailability(isrc)
|
||||
}
|
||||
|
||||
if availability.Tidal || availability.Amazon || availability.Deezer || availability.Qobuz {
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return availability, err
|
||||
}
|
||||
|
||||
return availability, fmt.Errorf("no platforms found")
|
||||
}
|
||||
|
||||
func qobuzNormalizeRelativeURL(rawURL string) string {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") {
|
||||
return rawURL
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "/") {
|
||||
return "https://www.qobuz.com" + rawURL
|
||||
}
|
||||
return "https://www.qobuz.com/" + rawURL
|
||||
}
|
||||
|
||||
func qobuzSlugifySegment(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
builder.WriteRune(r)
|
||||
lastDash = false
|
||||
default:
|
||||
if !lastDash {
|
||||
builder.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Trim(builder.String(), "-")
|
||||
}
|
||||
|
||||
func qobuzAlbumSlugURL(albumTitle string, albumID string) string {
|
||||
albumID = strings.TrimSpace(albumID)
|
||||
if albumID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
slug := qobuzSlugifySegment(albumTitle)
|
||||
if slug == "" {
|
||||
return fmt.Sprintf("https://www.qobuz.com/album/%s", albumID)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://www.qobuz.com/album/%s/%s", slug, albumID)
|
||||
}
|
||||
|
||||
func checkQobuzAvailability(isrc string) (bool, string) {
|
||||
var searchResp struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
Items []qobuzAvailabilityTrack `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
if err := doQobuzSignedJSONRequest("track/search", url.Values{
|
||||
"query": {strings.TrimSpace(isrc)},
|
||||
"limit": {"1"},
|
||||
}, &searchResp); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
item := searchResp.Tracks.Items[0]
|
||||
qobuzURL := strings.TrimSpace(item.Album.URL)
|
||||
if qobuzURL == "" {
|
||||
qobuzURL = qobuzNormalizeRelativeURL(item.Album.RelativeURL)
|
||||
}
|
||||
if qobuzURL == "" {
|
||||
qobuzURL = qobuzAlbumSlugURL(item.Album.Title, item.Album.ID)
|
||||
}
|
||||
if qobuzURL == "" && item.ID > 0 {
|
||||
qobuzURL = fmt.Sprintf("https://www.qobuz.com/us-en/track/%d", item.ID)
|
||||
}
|
||||
|
||||
return true, qobuzURL
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, "")
|
||||
if links != nil && links.DeezerURL != "" {
|
||||
deezerURL := normalizeDeezerTrackURL(links.DeezerURL)
|
||||
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
|
||||
return deezerURL, nil
|
||||
}
|
||||
|
||||
isrc := ""
|
||||
if links != nil {
|
||||
isrc = strings.TrimSpace(links.ISRC)
|
||||
}
|
||||
if isrc == "" {
|
||||
fallbackISRC, lookupErr := s.lookupSpotifyISRC(spotifyTrackID)
|
||||
if lookupErr == nil {
|
||||
isrc = fallbackISRC
|
||||
} else if err == nil {
|
||||
err = lookupErr
|
||||
}
|
||||
}
|
||||
|
||||
if isrc != "" {
|
||||
deezerURL, deezerErr := s.lookupDeezerTrackURLByISRC(isrc)
|
||||
if deezerErr == nil {
|
||||
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
|
||||
return deezerURL, nil
|
||||
}
|
||||
if err == nil {
|
||||
err = deezerErr
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", fmt.Errorf("deezer link not found")
|
||||
}
|
||||
|
||||
func getDeezerISRC(deezerURL string) (string, error) {
|
||||
trackID, err := extractDeezerTrackID(deezerURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(apiURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to call Deezer API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var deezerTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
ISRC string `json:"isrc"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&deezerTrack); err != nil {
|
||||
return "", fmt.Errorf("failed to decode Deezer API response: %w", err)
|
||||
}
|
||||
|
||||
if deezerTrack.ISRC == "" {
|
||||
return "", fmt.Errorf("ISRC not found in Deezer API response for track %s", trackID)
|
||||
}
|
||||
|
||||
fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
|
||||
return strings.ToUpper(strings.TrimSpace(deezerTrack.ISRC)), nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetISRC(spotifyID string) (string, error) {
|
||||
links, err := s.resolveSpotifyTrackLinks(spotifyID, "")
|
||||
if links != nil && links.ISRC != "" {
|
||||
return links.ISRC, nil
|
||||
}
|
||||
|
||||
if links != nil && links.DeezerURL != "" {
|
||||
if isrc, deezerErr := getDeezerISRC(links.DeezerURL); deezerErr == nil {
|
||||
return isrc, nil
|
||||
}
|
||||
}
|
||||
|
||||
isrc, lookupErr := s.lookupSpotifyISRC(spotifyID)
|
||||
if lookupErr == nil && isrc != "" {
|
||||
return isrc, nil
|
||||
}
|
||||
|
||||
if err != nil && lookupErr != nil {
|
||||
return "", fmt.Errorf("%v | %v", err, lookupErr)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if lookupErr != nil {
|
||||
return "", lookupErr
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ISRC not found")
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetISRCDirect(spotifyID string) (string, error) {
|
||||
return s.lookupSpotifyISRC(spotifyID)
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (*songLinkAPIResponse, error) {
|
||||
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(rawURL))
|
||||
if region != "" {
|
||||
apiURL += fmt.Sprintf("&userCountry=%s", url.QueryEscape(region))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call song.link: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return nil, fmt.Errorf("song.link returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview)))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read song.link response: %w", err)
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("song.link returned empty response")
|
||||
}
|
||||
|
||||
var parsed songLinkAPIResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode song.link response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupDeezerTrackURLByISRC(isrc string) (string, error) {
|
||||
apiURL := fmt.Sprintf("https://api.deezer.com/track/isrc:%s", strings.ToUpper(strings.TrimSpace(isrc)))
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to call Deezer ISRC API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Deezer ISRC API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
ID int64 `json:"id"`
|
||||
ISRC string `json:"isrc"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", fmt.Errorf("failed to decode Deezer ISRC response: %w", err)
|
||||
}
|
||||
|
||||
if payload.Link != "" {
|
||||
return normalizeDeezerTrackURL(payload.Link), nil
|
||||
}
|
||||
if payload.ID > 0 {
|
||||
return normalizeDeezerTrackURL(fmt.Sprintf("https://www.deezer.com/track/%d", payload.ID)), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("deezer track link not found for ISRC %s", isrc)
|
||||
}
|
||||
|
||||
func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if link, ok := resp.LinksByPlatform["tidal"]; ok && link.URL != "" && links.TidalURL == "" {
|
||||
links.TidalURL = strings.TrimSpace(link.URL)
|
||||
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")
|
||||
}
|
||||
|
||||
if link, ok := resp.LinksByPlatform["deezer"]; ok && link.URL != "" && links.DeezerURL == "" {
|
||||
links.DeezerURL = normalizeDeezerTrackURL(link.URL)
|
||||
fmt.Println("✓ Deezer URL found")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAmazonMusicURL(rawURL string) string {
|
||||
amazonURL := strings.TrimSpace(rawURL)
|
||||
if amazonURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.Contains(amazonURL, "trackAsin=") {
|
||||
parts := strings.Split(amazonURL, "trackAsin=")
|
||||
if len(parts) > 1 {
|
||||
trackAsin := strings.Split(parts[1], "&")[0]
|
||||
if trackAsin != "" {
|
||||
return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if match := amazonAlbumTrackPath.FindStringSubmatch(amazonURL); len(match) > 1 {
|
||||
return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", match[1])
|
||||
}
|
||||
|
||||
if match := amazonTrackPath.FindStringSubmatch(amazonURL); len(match) > 1 {
|
||||
return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", match[1])
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeDeezerTrackURL(rawURL string) string {
|
||||
trackID, err := extractDeezerTrackID(rawURL)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(rawURL)
|
||||
}
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", trackID)
|
||||
}
|
||||
|
||||
func extractDeezerTrackID(rawURL string) (string, error) {
|
||||
cleanURL := strings.TrimSpace(rawURL)
|
||||
if cleanURL == "" {
|
||||
return "", fmt.Errorf("empty Deezer URL")
|
||||
}
|
||||
|
||||
parts := strings.Split(cleanURL, "/track/")
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", rawURL)
|
||||
}
|
||||
|
||||
trackID := strings.Split(parts[1], "?")[0]
|
||||
trackID = strings.Trim(trackID, "/ ")
|
||||
if trackID == "" {
|
||||
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", rawURL)
|
||||
}
|
||||
|
||||
return trackID, nil
|
||||
}
|
||||
|
||||
func hasAnySongLinkData(links *resolvedTrackLinks) bool {
|
||||
if links == nil {
|
||||
return false
|
||||
}
|
||||
return links.TidalURL != "" || links.AmazonURL != "" || links.DeezerURL != ""
|
||||
}
|
||||
|
||||
func firstISRCMatch(body string) string {
|
||||
match := isrcPattern.FindStringSubmatch(strings.ToUpper(body))
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var songstatsScriptPattern = regexp.MustCompile(`(?is)<script[^>]+type=["']application/ld\+json["'][^>]*>(.*?)</script>`)
|
||||
|
||||
func (s *SongLinkClient) populateLinksFromSongstats(links *resolvedTrackLinks, isrc string) error {
|
||||
pageURL := fmt.Sprintf("https://songstats.com/%s?ref=ISRCFinder", strings.ToUpper(strings.TrimSpace(isrc)))
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, pageURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch Songstats page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Songstats returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read Songstats response: %w", err)
|
||||
}
|
||||
|
||||
matches := songstatsScriptPattern.FindAllStringSubmatch(string(body), -1)
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("Songstats JSON-LD not found")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, match := range matches {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
scriptBody := strings.TrimSpace(html.UnescapeString(match[1]))
|
||||
if scriptBody == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var payload interface{}
|
||||
if err := json.Unmarshal([]byte(scriptBody), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
before := *links
|
||||
collectSongstatsLinks(payload, links)
|
||||
if *links != before {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found && !hasAnySongLinkData(links) {
|
||||
return fmt.Errorf("no platform links found in Songstats")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectSongstatsLinks(value interface{}, links *resolvedTrackLinks) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]interface{}:
|
||||
if sameAs, ok := typed["sameAs"]; ok {
|
||||
applySongstatsSameAs(sameAs, links)
|
||||
}
|
||||
for _, nested := range typed {
|
||||
collectSongstatsLinks(nested, links)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, nested := range typed {
|
||||
collectSongstatsLinks(nested, links)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applySongstatsSameAs(value interface{}, links *resolvedTrackLinks) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
assignSongstatsLink(typed, links)
|
||||
case []interface{}:
|
||||
for _, item := range typed {
|
||||
if link, ok := item.(string); ok {
|
||||
assignSongstatsLink(link, links)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) {
|
||||
link := strings.TrimSpace(rawLink)
|
||||
if link == "" {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(link, "listen.tidal.com/track"):
|
||||
if links.TidalURL == "" {
|
||||
links.TidalURL = link
|
||||
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")
|
||||
}
|
||||
}
|
||||
case strings.Contains(link, "deezer.com"):
|
||||
if links.DeezerURL == "" {
|
||||
links.DeezerURL = normalizeDeezerTrackURL(link)
|
||||
fmt.Println("✓ Deezer URL found via Songstats")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
soundplateSpotifyAPIURL = "https://phpstack-822472-6184058.cloudwaysapps.com/api/spotify.php"
|
||||
soundplateRefererURL = "https://phpstack-822472-6184058.cloudwaysapps.com/?"
|
||||
soundplateUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
type soundplateSpotifyResponse struct {
|
||||
Name string `json:"name"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
AlbumType string `json:"album_type"`
|
||||
ArtworkURL string `json:"artwork_url"`
|
||||
ISRC string `json:"isrc"`
|
||||
Year string `json:"year"`
|
||||
SpotifyURL string `json:"spotify_url"`
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupSpotifyISRCViaSoundplate(spotifyTrackID string) (string, string, error) {
|
||||
normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
spotifyTrackURL := fmt.Sprintf("https://open.spotify.com/track/%s", normalizedTrackID)
|
||||
query := url.Values{}
|
||||
query.Set("q", spotifyTrackURL)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, soundplateSpotifyAPIURL+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create Soundplate ISRC request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", soundplateUserAgent)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Referer", soundplateRefererURL)
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9,id;q=0.8")
|
||||
req.Header.Set("Sec-CH-UA", "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"")
|
||||
req.Header.Set("Sec-CH-UA-Mobile", "?0")
|
||||
req.Header.Set("Sec-CH-UA-Platform", "\"Windows\"")
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
req.Header.Set("Priority", "u=1, i")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("Soundplate ISRC request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read Soundplate ISRC response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyPreview := strings.TrimSpace(string(body))
|
||||
if len(bodyPreview) > 256 {
|
||||
bodyPreview = bodyPreview[:256]
|
||||
}
|
||||
return "", "", fmt.Errorf("Soundplate ISRC returned status %d (%s)", resp.StatusCode, bodyPreview)
|
||||
}
|
||||
|
||||
var payload soundplateSpotifyResponse
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", "", fmt.Errorf("failed to decode Soundplate ISRC response: %w", err)
|
||||
}
|
||||
|
||||
isrc := firstISRCMatch(payload.ISRC)
|
||||
if isrc == "" {
|
||||
isrc = firstISRCMatch(string(body))
|
||||
}
|
||||
if isrc == "" {
|
||||
return "", "", fmt.Errorf("ISRC missing in Soundplate response")
|
||||
}
|
||||
|
||||
resolvedTrackID := ""
|
||||
if payload.SpotifyURL != "" {
|
||||
if trackID, err := extractSpotifyTrackID(payload.SpotifyURL); err == nil {
|
||||
resolvedTrackID = trackID
|
||||
}
|
||||
}
|
||||
|
||||
return isrc, resolvedTrackID, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
const (
|
||||
spotifyTOTPSecret = "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY"
|
||||
spotifyTOTPVersion = 61
|
||||
)
|
||||
|
||||
func generateSpotifyTOTP(now time.Time) (string, int, error) {
|
||||
key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", spotifyTOTPSecret))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
code, err := totp.GenerateCode(key.Secret(), now)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
return code, spotifyTOTPVersion, nil
|
||||
}
|
||||
@@ -1,956 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TidalDownloader struct {
|
||||
client *http.Client
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
apiURL string
|
||||
}
|
||||
|
||||
type TidalAPIResponse struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
|
||||
type TidalAPIResponseV2 struct {
|
||||
Version string `json:"version"`
|
||||
Data struct {
|
||||
TrackID int64 `json:"trackId"`
|
||||
AssetPresentation string `json:"assetPresentation"`
|
||||
AudioMode string `json:"audioMode"`
|
||||
AudioQuality string `json:"audioQuality"`
|
||||
ManifestMimeType string `json:"manifestMimeType"`
|
||||
ManifestHash string `json:"manifestHash"`
|
||||
Manifest string `json:"manifest"`
|
||||
BitDepth int `json:"bitDepth"`
|
||||
SampleRate int `json:"sampleRate"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type TidalBTSManifest struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Codecs string `json:"codecs"`
|
||||
EncryptionType string `json:"encryptionType"`
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
|
||||
func getConfiguredTidalAPIAttemptList() ([]string, error) {
|
||||
customAPI := GetCustomTidalAPISetting()
|
||||
if customAPI == "" {
|
||||
return nil, fmt.Errorf("no configured custom tidal api instance")
|
||||
}
|
||||
return []string{customAPI}, nil
|
||||
}
|
||||
|
||||
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", false, fmt.Errorf("directory error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
artistNameForFile := sanitizeFilename(spotifyArtistName)
|
||||
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
||||
if useFirstArtistOnly {
|
||||
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
|
||||
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||
}
|
||||
|
||||
trackTitleForFile := sanitizeFilename(spotifyTrackName)
|
||||
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
|
||||
|
||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
|
||||
outputFilename := filepath.Join(outputDir, filename)
|
||||
|
||||
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
|
||||
return outputFilename, alreadyExists, nil
|
||||
}
|
||||
|
||||
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
|
||||
trackTitle := spotifyTrackName
|
||||
artistName := spotifyArtistName
|
||||
albumTitle := spotifyAlbumName
|
||||
|
||||
type mbResult struct {
|
||||
ISRC string
|
||||
Metadata Metadata
|
||||
}
|
||||
|
||||
metaChan := make(chan mbResult, 1)
|
||||
if embedGenre && spotifyURL != "" {
|
||||
go func() {
|
||||
res := mbResult{}
|
||||
var isrc string
|
||||
parts := strings.Split(spotifyURL, "/")
|
||||
if len(parts) > 0 {
|
||||
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
||||
if sID != "" {
|
||||
client := NewSongLinkClient()
|
||||
if val, err := client.GetISRC(sID); err == nil {
|
||||
isrc = val
|
||||
}
|
||||
}
|
||||
}
|
||||
res.ISRC = isrc
|
||||
if isrc != "" {
|
||||
if ShouldSkipMusicBrainzMetadataFetch() {
|
||||
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
|
||||
} else {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
||||
res.Metadata = fetchedMeta
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
} else {
|
||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
metaChan <- res
|
||||
}()
|
||||
} else {
|
||||
close(metaChan)
|
||||
}
|
||||
|
||||
isrc := strings.TrimSpace(isrcOverride)
|
||||
var mbMeta Metadata
|
||||
if spotifyURL != "" {
|
||||
result := <-metaChan
|
||||
if isrc == "" {
|
||||
isrc = result.ISRC
|
||||
}
|
||||
mbMeta = result.Metadata
|
||||
}
|
||||
|
||||
upc := ""
|
||||
if spotifyURL != "" {
|
||||
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
|
||||
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
|
||||
isrc = strings.TrimSpace(identifiers.ISRC)
|
||||
}
|
||||
upc = strings.TrimSpace(identifiers.UPC)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Adding metadata...")
|
||||
|
||||
coverPath := ""
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = outputFilename + ".cover.jpg"
|
||||
coverClient := NewCoverClient()
|
||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||
coverPath = ""
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Spotify cover downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = 1
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: trackTitle,
|
||||
Artist: artistName,
|
||||
Album: albumTitle,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
TotalTracks: spotifyTotalTracks,
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Comment: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Composer: spotifyComposer,
|
||||
Separator: metadataSeparator,
|
||||
Description: "https://github.com/spotbye/SpotiFLAC",
|
||||
ISRC: isrc,
|
||||
UPC: upc,
|
||||
Genre: mbMeta.Genre,
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
||||
fmt.Printf("Tagging failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Metadata saved")
|
||||
}
|
||||
}
|
||||
|
||||
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
return &TidalDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
timeout: 5 * time.Second,
|
||||
maxRetries: 3,
|
||||
apiURL: apiURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||
apis, err := getConfiguredTidalAPIAttemptList()
|
||||
if err == nil && len(apis) > 0 {
|
||||
return apis, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
fmt.Println("Getting Tidal URL...")
|
||||
client := NewSongLinkClient()
|
||||
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get Tidal URL: %w", err)
|
||||
}
|
||||
|
||||
tidalURL := urls.TidalURL
|
||||
if tidalURL == "" {
|
||||
return "", fmt.Errorf("tidal link not found")
|
||||
}
|
||||
fmt.Printf("Found Tidal URL: %s\n", tidalURL)
|
||||
return tidalURL, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
||||
|
||||
parts := strings.Split(tidalURL, "/track/")
|
||||
if len(parts) < 2 {
|
||||
return 0, fmt.Errorf("invalid tidal URL format")
|
||||
}
|
||||
|
||||
trackIDStr := strings.Split(parts[1], "?")[0]
|
||||
trackIDStr = strings.TrimSpace(trackIDStr)
|
||||
|
||||
var trackID int64
|
||||
_, err := fmt.Sscanf(trackIDStr, "%d", &trackID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse track ID: %w", err)
|
||||
}
|
||||
|
||||
return trackID, nil
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
||||
fmt.Printf("Tidal API URL: %s\n", url)
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)")
|
||||
return "MANIFEST:" + v2Response.Data.Manifest, nil
|
||||
}
|
||||
|
||||
var apiResponses []TidalAPIResponse
|
||||
if err := json.Unmarshal(body, &apiResponses); err != nil {
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
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")
|
||||
return "", fmt.Errorf("no download URL in response")
|
||||
}
|
||||
|
||||
for _, item := range apiResponses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
fmt.Println("✓ Tidal download URL found")
|
||||
return item.OriginalTrackURL, nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("✗ No valid download URL in Tidal API response")
|
||||
return "", fmt.Errorf("download URL not found in response")
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) error {
|
||||
|
||||
if strings.HasPrefix(url, "MANIFEST:") {
|
||||
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath, quality)
|
||||
}
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
|
||||
fmt.Println("Download complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string, quality string) error {
|
||||
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||
}
|
||||
|
||||
isLosslessRequested := quality == "LOSSLESS" || quality == "HI_RES" || quality == "HI_RES_LOSSLESS"
|
||||
isActualLossless := strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == ""
|
||||
if isLosslessRequested && !isActualLossless {
|
||||
return fmt.Errorf("requested %s quality but Tidal provided lossy format (%s). Aborting download", quality, mimeType)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
doRequest := func(url string) (*http.Response, error) {
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
if directURL != "" && (strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == "") {
|
||||
fmt.Println("Downloading file...")
|
||||
|
||||
resp, err := doRequest(directURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
fmt.Println("Download complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
tempPath := outputPath + ".m4a.tmp"
|
||||
|
||||
if directURL != "" {
|
||||
fmt.Printf("Downloading non-FLAC file (%s)...\n", mimeType)
|
||||
|
||||
resp, err := doRequest(directURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, resp.Body)
|
||||
out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
|
||||
} else {
|
||||
|
||||
fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1)
|
||||
|
||||
out, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print("Downloading init segment... ")
|
||||
resp, err := doRequest(initURL)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to download init segment: %w", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to write init segment: %w", err)
|
||||
}
|
||||
fmt.Println("OK")
|
||||
|
||||
totalSegments := len(mediaURLs)
|
||||
var totalBytes int64
|
||||
lastTime := time.Now()
|
||||
var lastBytes int64
|
||||
for i, mediaURL := range mediaURLs {
|
||||
resp, err := doRequest(mediaURL)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
||||
}
|
||||
n, err := io.Copy(out, resp.Body)
|
||||
totalBytes += n
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
||||
}
|
||||
|
||||
mbDownloaded := float64(totalBytes) / (1024 * 1024)
|
||||
now := time.Now()
|
||||
timeDiff := now.Sub(lastTime).Seconds()
|
||||
var speedMBps float64
|
||||
if timeDiff > 0.1 {
|
||||
bytesDiff := float64(totalBytes - lastBytes)
|
||||
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||
SetDownloadSpeed(speedMBps)
|
||||
lastTime = now
|
||||
lastBytes = totalBytes
|
||||
}
|
||||
SetDownloadProgress(mbDownloaded)
|
||||
|
||||
fmt.Printf("\rDownloading: %.2f MB (%d/%d segments)", mbDownloaded, i+1, totalSegments)
|
||||
}
|
||||
|
||||
out.Close()
|
||||
|
||||
tempInfo, _ := os.Stat(tempPath)
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024))
|
||||
}
|
||||
|
||||
fmt.Println("Converting to FLAC...")
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg not found: %w", err)
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||
return fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffmpegPath, "-y", "-i", tempPath, "-vn", "-c:a", "flac", outputPath)
|
||||
setHideWindow(cmd)
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
|
||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
os.Rename(tempPath, m4aPath)
|
||||
return fmt.Errorf("ffmpeg conversion failed (M4A saved as %s): %w - %s", m4aPath, err, stderr.String())
|
||||
}
|
||||
|
||||
os.Remove(tempPath)
|
||||
fmt.Println("Download complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
fmt.Printf("Using Tidal URL: %s\n", tidalURL)
|
||||
|
||||
trackID, err := t.GetTrackIDFromURL(tidalURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if trackID == 0 {
|
||||
return "", fmt.Errorf("no track ID found")
|
||||
}
|
||||
|
||||
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if alreadyExists {
|
||||
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
|
||||
return "EXISTS:" + outputFilename, nil
|
||||
}
|
||||
|
||||
downloadURL, err := t.GetDownloadURL(trackID, quality)
|
||||
if err != nil {
|
||||
if isTidalHiResQuality(quality) && allowFallback {
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
return outputFilename, err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||
if err := t.DownloadFile(downloadURL, outputFilename, quality); err != nil {
|
||||
cleanupTidalDownloadArtifacts(outputFilename)
|
||||
return outputFilename, err
|
||||
}
|
||||
|
||||
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
||||
|
||||
fmt.Println("Done")
|
||||
fmt.Println("✓ Downloaded successfully from Tidal")
|
||||
return outputFilename, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
fmt.Printf("Using Tidal URL: %s\n", tidalURL)
|
||||
|
||||
trackID, err := t.GetTrackIDFromURL(tidalURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if trackID == 0 {
|
||||
return "", fmt.Errorf("no track ID found")
|
||||
}
|
||||
|
||||
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if alreadyExists {
|
||||
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
|
||||
return "EXISTS:" + outputFilename, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||
successAPI, err := t.downloadWithRotatingAPIs(trackID, outputFilename, quality, allowFallback)
|
||||
if err != nil {
|
||||
cleanupTidalDownloadArtifacts(outputFilename)
|
||||
return outputFilename, err
|
||||
}
|
||||
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")
|
||||
return outputFilename, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
|
||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
type SegmentTemplate struct {
|
||||
Initialization string `xml:"initialization,attr"`
|
||||
Media string `xml:"media,attr"`
|
||||
Timeline struct {
|
||||
Segments []struct {
|
||||
Duration int64 `xml:"d,attr"`
|
||||
Repeat int `xml:"r,attr"`
|
||||
} `xml:"S"`
|
||||
} `xml:"SegmentTimeline"`
|
||||
}
|
||||
|
||||
type MPD struct {
|
||||
XMLName xml.Name `xml:"MPD"`
|
||||
Period struct {
|
||||
AdaptationSets []struct {
|
||||
MimeType string `xml:"mimeType,attr"`
|
||||
Codecs string `xml:"codecs,attr"`
|
||||
Representations []struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Codecs string `xml:"codecs,attr"`
|
||||
Bandwidth int `xml:"bandwidth,attr"`
|
||||
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
|
||||
} `xml:"Representation"`
|
||||
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
|
||||
} `xml:"AdaptationSet"`
|
||||
} `xml:"Period"`
|
||||
}
|
||||
|
||||
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, mimeType string, err error) {
|
||||
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
||||
if err != nil {
|
||||
return "", "", nil, "", fmt.Errorf("failed to decode manifest: %w", err)
|
||||
}
|
||||
|
||||
manifestStr := string(manifestBytes)
|
||||
|
||||
if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
|
||||
var btsManifest TidalBTSManifest
|
||||
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
||||
return "", "", nil, "", fmt.Errorf("failed to parse BTS manifest: %w", err)
|
||||
}
|
||||
|
||||
if len(btsManifest.URLs) == 0 {
|
||||
return "", "", nil, "", fmt.Errorf("no URLs in BTS manifest")
|
||||
}
|
||||
|
||||
fmt.Printf("Manifest: BTS format (%s, %s)\n", btsManifest.MimeType, btsManifest.Codecs)
|
||||
return btsManifest.URLs[0], "", nil, btsManifest.MimeType, nil
|
||||
}
|
||||
|
||||
fmt.Println("Manifest: DASH format")
|
||||
|
||||
var mpd MPD
|
||||
var segTemplate *SegmentTemplate
|
||||
var dashMimeType string
|
||||
|
||||
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
|
||||
var selectedBandwidth int
|
||||
var selectedCodecs string
|
||||
var selectedMimeType string
|
||||
|
||||
for _, as := range mpd.Period.AdaptationSets {
|
||||
|
||||
if as.SegmentTemplate != nil {
|
||||
|
||||
if segTemplate == nil {
|
||||
segTemplate = as.SegmentTemplate
|
||||
selectedCodecs = as.Codecs
|
||||
selectedMimeType = as.MimeType
|
||||
}
|
||||
}
|
||||
|
||||
for _, rep := range as.Representations {
|
||||
if rep.SegmentTemplate != nil {
|
||||
if rep.Bandwidth > selectedBandwidth {
|
||||
selectedBandwidth = rep.Bandwidth
|
||||
segTemplate = rep.SegmentTemplate
|
||||
|
||||
if rep.Codecs != "" {
|
||||
selectedCodecs = rep.Codecs
|
||||
} else {
|
||||
selectedCodecs = as.Codecs
|
||||
}
|
||||
|
||||
selectedMimeType = as.MimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if selectedBandwidth > 0 {
|
||||
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
|
||||
dashMimeType = fmt.Sprintf("%s; codecs=\"%s\"", selectedMimeType, selectedCodecs)
|
||||
}
|
||||
}
|
||||
|
||||
var mediaTemplate string
|
||||
segmentCount := 0
|
||||
|
||||
if segTemplate != nil {
|
||||
initURL = segTemplate.Initialization
|
||||
mediaTemplate = segTemplate.Media
|
||||
|
||||
for _, seg := range segTemplate.Timeline.Segments {
|
||||
segmentCount += seg.Repeat + 1
|
||||
}
|
||||
}
|
||||
|
||||
if segmentCount > 0 && initURL != "" && mediaTemplate != "" {
|
||||
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||
|
||||
fmt.Printf("Parsed manifest via XML: %d segments\n", segmentCount)
|
||||
|
||||
for i := 1; i <= segmentCount; i++ {
|
||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||
mediaURLs = append(mediaURLs, mediaURL)
|
||||
}
|
||||
return "", initURL, mediaURLs, dashMimeType, nil
|
||||
}
|
||||
|
||||
fmt.Println("Using regex fallback for DASH manifest...")
|
||||
|
||||
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
||||
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
||||
|
||||
if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
||||
initURL = match[1]
|
||||
}
|
||||
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
||||
mediaTemplate = match[1]
|
||||
}
|
||||
|
||||
if initURL == "" {
|
||||
return "", "", nil, "", fmt.Errorf("no initialization URL found in manifest")
|
||||
}
|
||||
|
||||
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||
|
||||
segmentCount = 0
|
||||
|
||||
segTagRe := regexp.MustCompile(`<S\s+[^>]*>`)
|
||||
matches := segTagRe.FindAllString(manifestStr, -1)
|
||||
|
||||
for _, match := range matches {
|
||||
repeat := 0
|
||||
rRe := regexp.MustCompile(`r="(\d+)"`)
|
||||
if rMatch := rRe.FindStringSubmatch(match); len(rMatch) > 1 {
|
||||
fmt.Sscanf(rMatch[1], "%d", &repeat)
|
||||
}
|
||||
segmentCount += repeat + 1
|
||||
}
|
||||
|
||||
if segmentCount == 0 {
|
||||
return "", "", nil, "", fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
|
||||
}
|
||||
|
||||
fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount)
|
||||
|
||||
for i := 1; i <= segmentCount; i++ {
|
||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||
mediaURLs = append(mediaURLs, mediaURL)
|
||||
}
|
||||
|
||||
return "", initURL, mediaURLs, dashMimeType, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
|
||||
qualities := []string{quality}
|
||||
if isTidalHiResQuality(quality) && allowFallback {
|
||||
qualities = append(qualities, "LOSSLESS")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
apiURL, err := t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, candidateQuality, false)
|
||||
if err == nil {
|
||||
return apiURL, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no tidal api succeeded")
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
|
||||
apis, err := getConfiguredTidalAPIAttemptList()
|
||||
if err != nil && len(apis) == 0 {
|
||||
return "", fmt.Errorf("failed to load tidal api list: %w", err)
|
||||
}
|
||||
if len(apis) == 0 {
|
||||
return "", fmt.Errorf("no tidal apis available")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
errors := make([]string, 0, len(apis))
|
||||
|
||||
for _, apiURL := range apis {
|
||||
fmt.Printf("Trying Tidal API: %s\n", apiURL)
|
||||
|
||||
downloader := NewTidalDownloader(apiURL)
|
||||
downloadURL, err := downloader.GetDownloadURL(trackID, quality)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if err := downloader.DownloadFile(downloadURL, outputFilename, quality); err != nil {
|
||||
lastErr = err
|
||||
cleanupTidalDownloadArtifacts(outputFilename)
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
||||
continue
|
||||
}
|
||||
|
||||
return apiURL, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("all tidal apis failed")
|
||||
}
|
||||
|
||||
fmt.Println("All Tidal APIs failed:")
|
||||
for _, item := range errors {
|
||||
fmt.Printf(" ✗ %s\n", item)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all tidal apis failed for quality %s: %w", quality, lastErr)
|
||||
}
|
||||
|
||||
func cleanupTidalDownloadArtifacts(outputPath string) {
|
||||
if outputPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.Remove(outputPath)
|
||||
_ = os.Remove(outputPath + ".m4a.tmp")
|
||||
}
|
||||
|
||||
func isTidalHiResQuality(quality string) bool {
|
||||
normalized := strings.TrimSpace(strings.ToUpper(quality))
|
||||
return normalized == "HI_RES" || normalized == "HI_RES_LOSSLESS"
|
||||
}
|
||||
|
||||
func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, extra ...string) string {
|
||||
var filename string
|
||||
isrc := ""
|
||||
if len(extra) > 0 {
|
||||
isrc = SanitizeOptionalFilename(extra[0])
|
||||
}
|
||||
|
||||
numberToUse := position
|
||||
if useAlbumTrackNumber && trackNumber > 0 {
|
||||
numberToUse = trackNumber
|
||||
}
|
||||
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
if strings.Contains(format, "{") {
|
||||
filename = format
|
||||
filename = strings.ReplaceAll(filename, "{title}", title)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", artist)
|
||||
filename = strings.ReplaceAll(filename, "{album}", album)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||
filename = strings.ReplaceAll(filename, "{isrc}", isrc)
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
if numberToUse > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
|
||||
} else {
|
||||
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
|
||||
switch format {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||
case "title":
|
||||
filename = title
|
||||
default:
|
||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||
}
|
||||
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".flac"
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package backend
|
||||
|
||||
import "strings"
|
||||
|
||||
const preferredUPCTagKey = "UPC"
|
||||
|
||||
var ffprobeUPCTagKeys = []string{
|
||||
"upc",
|
||||
"barcode",
|
||||
"wm/upc",
|
||||
"txxx:upc",
|
||||
"txxx:barcode",
|
||||
"txxx/upc",
|
||||
"txxx/barcode",
|
||||
"----:com.apple.itunes:upc",
|
||||
"----:com.apple.itunes:barcode",
|
||||
}
|
||||
|
||||
func assignPreferredUPC(current *string, incoming string, preferred bool) {
|
||||
incoming = strings.TrimSpace(incoming)
|
||||
if incoming == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if preferred || strings.TrimSpace(*current) == "" {
|
||||
*current = incoming
|
||||
}
|
||||
}
|
||||
|
||||
func classifyUPCDescription(description string) (matched bool, preferred bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(description)) {
|
||||
case preferredUPCTagKey:
|
||||
return true, true
|
||||
case "BARCODE":
|
||||
return true, false
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func firstPreferredFFprobeUPCValue(tags map[string]string) string {
|
||||
for _, key := range ffprobeUPCTagKeys {
|
||||
value := strings.TrimSpace(tags[key])
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user