.clean up the code

This commit is contained in:
429Enjoyer
2026-05-20 05:56:16 +07:00
parent b3ebef5ab9
commit 254022d81d
460 changed files with 0 additions and 51905 deletions
-537
View File
@@ -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)
}
-214
View File
@@ -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")
}
-90
View File
@@ -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
}
-249
View File
@@ -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
}
-595
View File
@@ -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
}
-44
View File
@@ -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
}
-947
View File
@@ -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
}
-12
View File
@@ -1,12 +0,0 @@
//go:build !windows
// +build !windows
package backend
import (
"os/exec"
)
func setHideWindow(cmd *exec.Cmd) {
}
-15
View File
@@ -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,
}
}
-53
View File
@@ -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
}
-45
View File
@@ -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
}
-7
View File
@@ -1,7 +0,0 @@
//go:build !darwin
package backend
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
return nil
}
-503
View File
@@ -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
}
-239
View File
@@ -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)
}
-115
View File
@@ -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
}
-323
View File
@@ -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))
})
}
-20
View File
@@ -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
}
-137
View File
@@ -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)
})
}
-464
View File
@@ -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")
}
-22
View File
@@ -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))
}
-154
View File
@@ -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, " | "))
}
-540
View File
@@ -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, &centiseconds)
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
View File
File diff suppressed because it is too large Load Diff
-331
View File
@@ -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
}
-419
View File
@@ -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()
}
}
-88
View File
@@ -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
}
-215
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-407
View File
@@ -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)
}
-106
View File
@@ -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...)
}
-91
View File
@@ -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)
}
-223
View File
@@ -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
}
-505
View File
@@ -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])
}
-128
View File
@@ -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")
}
}
}
-95
View File
@@ -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
}
-1753
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-28
View File
@@ -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
}
-956
View File
@@ -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, "&amp;", "&")
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&amp;", "&")
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, "&amp;", "&")
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&amp;", "&")
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"
}
-50
View File
@@ -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 ""
}