diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3e09dd5..66ca432 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -81,13 +81,13 @@ jobs:
- name: Prepare artifacts
run: |
mkdir -p dist
- Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC.exe"
+ Compress-Archive -Path "build\bin\SpotiFLAC.exe" -DestinationPath "dist\spotiflac-windows.zip" -Force
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
- name: windows-portable
- path: dist/SpotiFLAC.exe
+ name: windows-bundle
+ path: dist/spotiflac-windows.zip
retention-days: 7
build-macos:
@@ -147,36 +147,33 @@ jobs:
- name: Build application
run: wails build -platform darwin/universal
- - name: Create DMG
+ - name: Create macOS bundle
run: |
mkdir -p dist
- # Install create-dmg if not available
- brew install create-dmg || true
-
- # Create DMG
- create-dmg \
- --volname "SpotiFLAC" \
- --window-pos 200 120 \
- --window-size 600 400 \
- --icon-size 100 \
- --icon "SpotiFLAC.app" 175 120 \
- --hide-extension "SpotiFLAC.app" \
- --app-drop-link 425 120 \
- "dist/SpotiFLAC.dmg" \
- "build/bin/SpotiFLAC.app" || \
- # Fallback to hdiutil if create-dmg fails
- hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC.dmg
+ ditto -c -k --sequesterRsrc --keepParent "build/bin/SpotiFLAC.app" "dist/spotiflac-macos-bundle.zip"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
- name: macos-portable
- path: dist/SpotiFLAC.dmg
+ name: macos-bundle
+ path: dist/spotiflac-macos-bundle.zip
retention-days: 7
build-linux:
- name: Build Linux
- runs-on: ubuntu-24.04
+ name: Build Linux (${{ matrix.arch }})
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: amd64
+ goarch: amd64
+ runner: ubuntu-24.04
+ appimage_arch: x86_64
+ - arch: arm64
+ goarch: arm64
+ runner: ubuntu-24.04-arm
+ appimage_arch: aarch64
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -222,10 +219,15 @@ jobs:
- name: Install dependencies
run: |
sudo apt-get update
- sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick upx-ucl
+ PACKAGES="libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick"
+ if [ "${{ matrix.goarch }}" = "amd64" ]; then
+ PACKAGES="$PACKAGES upx-ucl"
+ fi
+ sudo apt-get install -y $PACKAGES
# Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility)
- sudo ln -sf /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.1.pc /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
+ MULTIARCH="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
+ sudo ln -sf "/usr/lib/${MULTIARCH}/pkgconfig/webkit2gtk-4.1.pc" "/usr/lib/${MULTIARCH}/pkgconfig/webkit2gtk-4.0.pc"
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
@@ -237,9 +239,10 @@ jobs:
pnpm run generate-icon
- name: Build application
- run: wails build -platform linux/amd64
+ run: wails build -platform linux/${{ matrix.goarch }}
- name: Compress with UPX
+ if: matrix.goarch == 'amd64'
run: |
upx --best --lzma build/bin/SpotiFLAC
@@ -248,13 +251,13 @@ jobs:
uses: actions/cache@v4
with:
path: appimagetool
- key: appimagetool-x86_64-v1
+ key: appimagetool-${{ matrix.appimage_arch }}-v1
- name: Download appimagetool
if: steps.cache-appimagetool.outputs.cache-hit != 'true'
run: |
- wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage || \
- wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
+ wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${{ matrix.appimage_arch }}.AppImage || \
+ wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${{ matrix.appimage_arch }}.AppImage
- name: Make appimagetool executable
run: chmod +x appimagetool
@@ -309,13 +312,18 @@ jobs:
# Create AppImage
mkdir -p dist
- ARCH=x86_64 ./appimagetool --no-appstream AppDir dist/SpotiFLAC.AppImage
+ if [ "${{ matrix.goarch }}" = "arm64" ]; then
+ RELEASE_ARCH="arm64v8"
+ else
+ RELEASE_ARCH="amd64"
+ fi
+ ARCH=${{ matrix.appimage_arch }} ./appimagetool --no-appstream AppDir "dist/SpotiFLAC-${RELEASE_ARCH}.AppImage"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
- name: linux-portable
- path: dist/SpotiFLAC.AppImage
+ name: linux-appimage-${{ matrix.arch }}
+ path: dist/*.AppImage
retention-days: 7
create-release:
@@ -343,6 +351,13 @@ jobs:
- name: Display structure of downloaded files
run: ls -R artifacts
+ - name: Create Linux bundle
+ run: |
+ mkdir -p release/SpotiFLAC-linux-bundle
+ cp artifacts/linux-appimage-amd64/*.AppImage release/SpotiFLAC-linux-bundle/
+ cp artifacts/linux-appimage-arm64/*.AppImage release/SpotiFLAC-linux-bundle/
+ tar -czf release/spotiflac-linux-bundle.tar.gz -C release SpotiFLAC-linux-bundle
+
- name: Create Release
uses: softprops/action-gh-release@v2
with:
@@ -354,15 +369,20 @@ jobs:
## Downloads
- - `SpotiFLAC.exe` - Windows
- - `SpotiFLAC.dmg` - macOS
- - `SpotiFLAC.AppImage` - Linux
+ - `spotiflac-windows.zip` - amd64
+ - `spotiflac-macos-bundle.zip` - amd64 + arm64
+ - `spotiflac-linux-bundle.tar.gz` - amd64 + arm64v8
Linux Requirements
The AppImage requires `webkit2gtk-4.1` to be installed on your system:
+ Choose the correct AppImage after extracting the bundle:
+
+ - `SpotiFLAC-amd64.AppImage` - amd64
+ - `SpotiFLAC-arm64v8.AppImage` - arm64v8
+
**Ubuntu/Debian:**
```bash
sudo apt install libwebkit2gtk-4.1-0
@@ -380,14 +400,14 @@ jobs:
After installing the dependency, make the AppImage executable:
```bash
- chmod +x SpotiFLAC.AppImage
- ./SpotiFLAC.AppImage
+ tar -xzf spotiflac-linux-bundle.tar.gz
+ chmod +x SpotiFLAC-*.AppImage
```
files: |
- artifacts/windows-portable/*.exe
- artifacts/macos-portable/*.dmg
- artifacts/linux-portable/*.AppImage
+ artifacts/windows-bundle/*.zip
+ artifacts/macos-bundle/*.zip
+ release/spotiflac-linux-bundle.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/README.md b/README.md
index 2261677..d3604ab 100644
--- a/README.md
+++ b/README.md
@@ -10,23 +10,23 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
[](https://t.me/spotiflac)
[](https://t.me/spotiflac_chat)
-### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
+### [Download](https://github.com/spotbye/SpotiFLAC/releases)

## Other projects
-### [SpotiFLAC Next](https://github.com/afkarxyz/SpotiFLAC-Next)
+### [SpotiFLAC Next](https://github.com/spotbye/SpotiFLAC-Next)
Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Apple Music — no account required.
-### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
+### [SpotiDownloader](https://github.com/spotbye/SpotiDownloader)
Get Spotify tracks, albums, playlists and discography in MP3 and FLAC.
-### [SpotubeDL](https://spotubedl.com)
+### [SpotubeDL.com](https://spotubedl.com)
-Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
+Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
diff --git a/app.go b/app.go
index 05c24d7..998b7c5 100644
--- a/app.go
+++ b/app.go
@@ -14,6 +14,7 @@ import (
"net/http"
"strings"
+ "sync"
"time"
"github.com/afkarxyz/SpotiFLAC/backend"
@@ -25,7 +26,22 @@ type App struct {
ctx context.Context
}
+type CurrentIPInfo struct {
+ IP string `json:"ip"`
+ Country string `json:"country"`
+ CountryCode string `json:"country_code,omitempty"`
+ Source string `json:"source,omitempty"`
+}
+
const checkOperationTimeout = 10 * time.Second
+const unifiedStatusAPIURL = "https://api-status.afkarxyz.qzz.io/api/status/spotiflac/"
+const unifiedStatusCacheTTL = 5 * time.Second
+
+var (
+ unifiedStatusCacheMu sync.Mutex
+ unifiedStatusCacheBody string
+ unifiedStatusCacheExpiry time.Time
+)
func NewApp() *App {
return &App{}
@@ -78,6 +94,42 @@ func containsStreamingURL(body []byte) bool {
return isStreamingURL(trimmedBody)
}
+func containsLRCLIBResults(body []byte) bool {
+ trimmedBody := strings.TrimSpace(string(body))
+ if trimmedBody == "" {
+ return false
+ }
+
+ var searchResults []map[string]interface{}
+ if err := json.Unmarshal(body, &searchResults); err == nil {
+ return len(searchResults) > 0
+ }
+
+ var exactResult map[string]interface{}
+ if err := json.Unmarshal(body, &exactResult); err == nil {
+ return len(exactResult) > 0
+ }
+
+ return false
+}
+
+func containsMusicBrainzResults(body []byte) bool {
+ trimmedBody := strings.TrimSpace(string(body))
+ if trimmedBody == "" {
+ return false
+ }
+
+ var payload struct {
+ Count int `json:"count"`
+ Recordings []json.RawMessage `json:"recordings"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return false
+ }
+
+ return payload.Count > 0 || len(payload.Recordings) > 0
+}
+
func isStreamingURL(raw string) bool {
candidate := strings.TrimSpace(raw)
if candidate == "" {
@@ -92,6 +144,179 @@ func isStreamingURL(raw string) bool {
return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
}
+func previewResponseBody(body []byte, maxLen int) string {
+ preview := strings.TrimSpace(string(body))
+ if maxLen > 0 && len(preview) > maxLen {
+ return preview[:maxLen] + "..."
+ }
+ return preview
+}
+
+func fetchUnifiedStatusPayload(forceRefresh bool, endpoint string) (string, error) {
+ unifiedStatusCacheMu.Lock()
+ defer unifiedStatusCacheMu.Unlock()
+
+ if !forceRefresh && unifiedStatusCacheBody != "" && time.Now().Before(unifiedStatusCacheExpiry) {
+ return unifiedStatusCacheBody, nil
+ }
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ maxRetries := 3
+ var lastErr error
+
+ for i := 0; i < maxRetries; i++ {
+ req, err := http.NewRequest(http.MethodGet, endpoint, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create unified status request: %w", err)
+ }
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := client.Do(req)
+ if err == nil {
+ body, readErr := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if readErr != nil {
+ lastErr = fmt.Errorf("attempt %d: failed reading response: %w", i+1, readErr)
+ } else if resp.StatusCode != http.StatusOK {
+ lastErr = fmt.Errorf("attempt %d: returned status %d (%s)", i+1, resp.StatusCode, previewResponseBody(body, 200))
+ } else {
+ payload := strings.TrimSpace(string(body))
+ if payload == "" {
+ lastErr = fmt.Errorf("attempt %d: empty response body", i+1)
+ } else {
+ unifiedStatusCacheBody = payload
+ unifiedStatusCacheExpiry = time.Now().Add(unifiedStatusCacheTTL)
+ return payload, nil
+ }
+ }
+ } else {
+ lastErr = fmt.Errorf("attempt %d: connection failed: %w", i+1, err)
+ }
+
+ if i < maxRetries-1 {
+ time.Sleep(1 * time.Second)
+ }
+ }
+
+ if lastErr == nil {
+ lastErr = fmt.Errorf("unknown error")
+ }
+
+ return "", fmt.Errorf("unified status API failed after %d retries: %w", maxRetries, lastErr)
+}
+
+func fetchCurrentIPInfo() (CurrentIPInfo, error) {
+ type ipwhoisResponse struct {
+ Success bool `json:"success"`
+ IP string `json:"ip"`
+ Country string `json:"country"`
+ CountryCode string `json:"country_code"`
+ Message string `json:"message"`
+ }
+ type ipapiResponse struct {
+ IP string `json:"ip"`
+ Country string `json:"country_name"`
+ CountryCode string `json:"country_code"`
+ Error bool `json:"error"`
+ Reason string `json:"reason"`
+ }
+
+ client := &http.Client{Timeout: 8 * time.Second}
+ tryFetch := func(source, reqURL string, parse func(body []byte) (CurrentIPInfo, error)) (CurrentIPInfo, error) {
+ req, err := http.NewRequest(http.MethodGet, reqURL, nil)
+ if err != nil {
+ return CurrentIPInfo{}, err
+ }
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return CurrentIPInfo{}, err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return CurrentIPInfo{}, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return CurrentIPInfo{}, fmt.Errorf("%s returned status %d: %s", source, resp.StatusCode, previewResponseBody(body, 200))
+ }
+
+ info, err := parse(body)
+ if err != nil {
+ return CurrentIPInfo{}, err
+ }
+ info.Source = source
+ return info, nil
+ }
+
+ info, err := tryFetch("ipwho.is", "https://ipwho.is/", func(body []byte) (CurrentIPInfo, error) {
+ var payload ipwhoisResponse
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return CurrentIPInfo{}, err
+ }
+ if !payload.Success {
+ return CurrentIPInfo{}, fmt.Errorf("ipwho.is lookup failed: %s", strings.TrimSpace(payload.Message))
+ }
+ if strings.TrimSpace(payload.IP) == "" || strings.TrimSpace(payload.Country) == "" {
+ return CurrentIPInfo{}, fmt.Errorf("ipwho.is returned incomplete IP data")
+ }
+ return CurrentIPInfo{
+ IP: strings.TrimSpace(payload.IP),
+ Country: strings.TrimSpace(payload.Country),
+ CountryCode: strings.TrimSpace(payload.CountryCode),
+ }, nil
+ })
+ if err == nil {
+ return info, nil
+ }
+ firstErr := err
+
+ info, err = tryFetch("ipapi.co", "https://ipapi.co/json/", func(body []byte) (CurrentIPInfo, error) {
+ var payload ipapiResponse
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return CurrentIPInfo{}, err
+ }
+ if payload.Error {
+ return CurrentIPInfo{}, fmt.Errorf("ipapi.co lookup failed: %s", strings.TrimSpace(payload.Reason))
+ }
+ if strings.TrimSpace(payload.IP) == "" || strings.TrimSpace(payload.Country) == "" {
+ return CurrentIPInfo{}, fmt.Errorf("ipapi.co returned incomplete IP data")
+ }
+ return CurrentIPInfo{
+ IP: strings.TrimSpace(payload.IP),
+ Country: strings.TrimSpace(payload.Country),
+ CountryCode: strings.TrimSpace(payload.CountryCode),
+ }, nil
+ })
+ if err == nil {
+ return info, nil
+ }
+
+ return CurrentIPInfo{}, fmt.Errorf("failed to detect public IP: %v; fallback failed: %v", firstErr, err)
+}
+
+func (a *App) GetCurrentIPInfo() (string, error) {
+ info, err := fetchCurrentIPInfo()
+ if err != nil {
+ return "", err
+ }
+
+ payload, err := json.Marshal(info)
+ if err != nil {
+ return "", err
+ }
+
+ return string(payload), nil
+}
+
+func (a *App) FetchUnifiedAPIStatus(forceRefresh bool) (string, error) {
+ return fetchUnifiedStatusPayload(forceRefresh, unifiedStatusAPIURL)
+}
+
func (a *App) getFirstArtist(artistString string) string {
if artistString == "" {
return ""
@@ -142,7 +367,7 @@ type DownloadRequest struct {
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
- ApiURL string `json:"api_url,omitempty"`
+ TidalAPIURL string `json:"tidal_api_url,omitempty"`
OutputDir string `json:"output_dir,omitempty"`
AudioFormat string `json:"audio_format,omitempty"`
FilenameFormat string `json:"filename_format,omitempty"`
@@ -159,8 +384,10 @@ type DownloadRequest struct {
SpotifyDiscNumber int `json:"spotify_disc_number,omitempty"`
SpotifyTotalTracks int `json:"spotify_total_tracks,omitempty"`
SpotifyTotalDiscs int `json:"spotify_total_discs,omitempty"`
+ ISRC string `json:"isrc,omitempty"`
Copyright string `json:"copyright,omitempty"`
Publisher string `json:"publisher,omitempty"`
+ Composer string `json:"composer,omitempty"`
PlaylistName string `json:"playlist_name,omitempty"`
PlaylistOwner string `json:"playlist_owner,omitempty"`
AllowFallback bool `json:"allow_fallback"`
@@ -245,27 +472,6 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
}
}
- if err == nil && settings != nil {
- if useAPI, ok := settings["useSpotFetchAPI"].(bool); ok && useAPI {
- if apiURL, ok := settings["spotFetchAPIUrl"].(string); ok && apiURL != "" {
-
- data, err := backend.GetSpotifyDataWithAPI(ctx, req.URL, true, apiURL, req.Batch, time.Duration(req.Delay*float64(time.Second)), separator, func(tracks interface{}) {
- runtime.EventsEmit(a.ctx, "metadata-stream", tracks)
- })
- if err != nil {
- return "", fmt.Errorf("failed to fetch metadata from API: %v", err)
- }
-
- jsonData, err := json.MarshalIndent(data, "", " ")
- if err != nil {
- return "", fmt.Errorf("failed to encode response: %v", err)
- }
-
- return string(jsonData), nil
- }
- }
- }
-
data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)), separator, func(tracks interface{}) {
runtime.EventsEmit(a.ctx, "metadata-stream", tracks)
})
@@ -362,6 +568,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.FilenameFormat == "" {
req.FilenameFormat = "title-artist"
}
+ if req.ISRC == "" && strings.Contains(req.FilenameFormat, "{isrc}") && req.SpotifyID != "" {
+ req.ISRC = backend.ResolveTrackISRC(req.SpotifyID)
+ }
itemID := req.ItemID
if itemID == "" {
@@ -384,25 +593,26 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
spotifyURL = fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID)
}
- if req.SpotifyID != "" && (req.Copyright == "" || req.Publisher == "" || req.SpotifyTotalDiscs == 0 || req.ReleaseDate == "" || req.SpotifyTotalTracks == 0 || req.SpotifyTrackNumber == 0) {
+ metadataSeparator := req.Separator
+ if metadataSeparator == "" {
+ metadataSeparator = ", "
+ metadataSettings, _ := a.LoadSettings()
+ if metadataSettings != nil {
+ if sep, ok := metadataSettings["separator"].(string); ok {
+ if sep == "semicolon" {
+ metadataSeparator = "; "
+ } else if sep == "comma" {
+ metadataSeparator = ", "
+ }
+ }
+ }
+ }
+
+ if req.SpotifyID != "" && (req.Copyright == "" || req.Publisher == "" || req.Composer == "" || req.SpotifyTotalDiscs == 0 || req.ReleaseDate == "" || req.SpotifyTotalTracks == 0 || req.SpotifyTrackNumber == 0) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
trackURL := fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID)
- metadataSeparator := req.Separator
- if metadataSeparator == "" {
- metadataSeparator = ", "
- metadataSettings, _ := a.LoadSettings()
- if metadataSettings != nil {
- if sep, ok := metadataSettings["separator"].(string); ok {
- if sep == "semicolon" {
- metadataSeparator = "; "
- } else if sep == "comma" {
- metadataSeparator = ", "
- }
- }
- }
- }
trackData, err := backend.GetFilteredSpotifyData(ctx, trackURL, false, 0, metadataSeparator, nil)
if err == nil {
@@ -410,6 +620,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
Track struct {
Copyright string `json:"copyright"`
Publisher string `json:"publisher"`
+ Composer string `json:"composer"`
TotalDiscs int `json:"total_discs"`
TotalTracks int `json:"total_tracks"`
TrackNumber int `json:"track_number"`
@@ -425,6 +636,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.Publisher == "" && trackResp.Track.Publisher != "" {
req.Publisher = trackResp.Track.Publisher
}
+ if req.Composer == "" && trackResp.Track.Composer != "" {
+ req.Composer = trackResp.Track.Composer
+ }
if req.SpotifyTotalDiscs == 0 && trackResp.Track.TotalDiscs > 0 {
req.SpotifyTotalDiscs = trackResp.Track.TotalDiscs
}
@@ -443,19 +657,21 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
if req.TrackName != "" && req.ArtistName != "" {
- expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber)
+ expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber, req.ISRC)
expectedPath := filepath.Join(req.OutputDir, expectedFilename)
- if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
+ if !backend.GetRedownloadWithSuffixSetting() {
+ if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
- backend.SkipDownloadItem(itemID, expectedPath)
- return DownloadResponse{
- Success: true,
- Message: "File already exists",
- File: expectedPath,
- AlreadyExists: true,
- ItemID: itemID,
- }, nil
+ backend.SkipDownloadItem(itemID, expectedPath)
+ return DownloadResponse{
+ Success: true,
+ Message: "File already exists",
+ File: expectedPath,
+ AlreadyExists: true,
+ ItemID: itemID,
+ }, nil
+ }
}
}
@@ -500,38 +716,41 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
downloader := backend.NewAmazonDownloader()
if req.ServiceURL != "" {
- filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
+ filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else {
- filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
+ filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
}
case "tidal":
- if req.ApiURL == "" || req.ApiURL == "auto" {
+ if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
downloader := backend.NewTidalDownloader("")
if req.ServiceURL != "" {
- filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
+ filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else {
- filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
+ filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
}
} else {
- downloader := backend.NewTidalDownloader(req.ApiURL)
+ downloader := backend.NewTidalDownloader(req.TidalAPIURL)
if req.ServiceURL != "" {
- filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
+ filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else {
- filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
+ filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
}
}
case "qobuz":
- fmt.Println("Waiting for ISRC (Qobuz dependency)...")
- isrc := <-isrcChan
+ isrc := strings.TrimSpace(req.ISRC)
+ if isrc == "" {
+ fmt.Println("Waiting for ISRC (Qobuz dependency)...")
+ isrc = <-isrcChan
+ }
downloader := backend.NewQobuzDownloader()
quality := req.AudioFormat
if quality == "" {
quality = "6"
}
- filename, err = downloader.DownloadTrackWithISRC(isrc, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
+ filename, err = downloader.DownloadTrackWithISRC(isrc, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
default:
return DownloadResponse{
@@ -832,6 +1051,10 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
checkURL = fmt.Sprintf("%s/api/track/360735657?quality=27", apiURL)
} else if apiType == "amazon" {
checkURL = fmt.Sprintf("%s/status", apiURL)
+ } else if apiType == "lrclib" {
+ checkURL = fmt.Sprintf("%s/api/search?artist_name=Adele&track_name=Hello", strings.TrimRight(apiURL, "/"))
+ } else if apiType == "musicbrainz" {
+ checkURL = fmt.Sprintf("%s/ws/2/recording?query=%s&fmt=json&limit=1", strings.TrimRight(apiURL, "/"), url.QueryEscape(`recording:"Hello" AND artist:"Adele"`))
} else {
checkURL = apiURL
}
@@ -842,6 +1065,7 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
return false, 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")
+ req.Header.Set("Accept", "application/json")
maxRetries := 3
for i := 0; i < maxRetries; i++ {
@@ -865,7 +1089,15 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
return true, nil
}
- if apiType != "amazon" && apiType != "qobuz" && apiType != "qbz" && statusCode == 200 {
+ if apiType == "lrclib" && statusCode == 200 && containsLRCLIBResults(body) {
+ return true, nil
+ }
+
+ if apiType == "musicbrainz" && statusCode == 200 && containsMusicBrainzResults(body) {
+ return true, nil
+ }
+
+ if apiType != "amazon" && apiType != "qobuz" && apiType != "qbz" && apiType != "lrclib" && apiType != "musicbrainz" && statusCode == 200 {
return true, nil
}
}
@@ -876,10 +1108,17 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
return false, nil
})
if err != nil {
+ if apiType == "musicbrainz" {
+ backend.SetMusicBrainzStatusCheckResult(false)
+ }
fmt.Printf("CheckAPIStatus timeout/error for %s (%s): %v\n", apiType, apiURL, err)
return false
}
+ if apiType == "musicbrainz" {
+ backend.SetMusicBrainzStatusCheckResult(isOnline)
+ }
+
return isOnline
}
@@ -920,6 +1159,34 @@ func (a *App) ClearFetchHistoryByType(itemType string) error {
return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC")
}
+func (a *App) GetRecentFetches() (string, error) {
+ items, err := backend.LoadRecentFetches()
+ if err != nil {
+ return "", err
+ }
+
+ data, err := json.Marshal(items)
+ if err != nil {
+ return "", err
+ }
+
+ return string(data), nil
+}
+
+func (a *App) SaveRecentFetches(payload string) error {
+ payload = strings.TrimSpace(payload)
+ if payload == "" {
+ payload = "[]"
+ }
+
+ var items []backend.RecentFetchItem
+ if err := json.Unmarshal([]byte(payload), &items); err != nil {
+ return err
+ }
+
+ return backend.SaveRecentFetches(items)
+}
+
func (a *App) SaveSpectrumImage(audioFilePath string, base64Data string) (string, error) {
if audioFilePath == "" || base64Data == "" {
return "", fmt.Errorf("file path and image data are required")
@@ -951,6 +1218,7 @@ type LyricsDownloadRequest struct {
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
+ ISRC string `json:"isrc,omitempty"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
@@ -975,6 +1243,7 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR
AlbumName: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ReleaseDate: req.ReleaseDate,
+ ISRC: req.ISRC,
OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber,
@@ -1398,6 +1667,7 @@ type CheckFileExistenceRequest struct {
AlbumName string `json:"album_name,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
+ ISRC string `json:"isrc,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
Position int `json:"position,omitempty"`
@@ -1427,6 +1697,7 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
}
defaultFilenameFormat := "title-artist"
+ redownloadWithSuffix := backend.GetRedownloadWithSuffixSetting()
type result struct {
index int
@@ -1477,6 +1748,10 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
if filenameFormat == "" {
filenameFormat = defaultFilenameFormat
}
+ isrc := strings.TrimSpace(t.ISRC)
+ if isrc == "" && strings.Contains(filenameFormat, "{isrc}") && t.SpotifyID != "" {
+ isrc = backend.ResolveTrackISRC(t.SpotifyID)
+ }
trackNumber := t.Position
if t.UseAlbumTrackNumber && t.TrackNumber > 0 {
@@ -1501,6 +1776,7 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
trackNumber,
t.DiscNumber,
t.UseAlbumTrackNumber,
+ isrc,
)
expectedFilename := strings.TrimSuffix(expectedFilenameBase, ".flac") + fileExt
@@ -1511,13 +1787,17 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
}
expectedPath := filepath.Join(targetDir, expectedFilename)
-
- if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
- res.Exists = true
- res.FilePath = expectedPath
+ if redownloadWithSuffix {
+ expectedPath, _ = backend.ResolveOutputPathForDownload(expectedPath, true)
+ res.FilePath = filepath.Base(expectedPath)
} else {
+ if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
+ res.Exists = true
+ res.FilePath = expectedPath
+ } else {
- res.FilePath = expectedFilename
+ res.FilePath = expectedFilename
+ }
}
resultsChan <- result{index: idx, result: res}
@@ -1567,6 +1847,10 @@ func (a *App) SkipDownloadItem(itemID, filePath string) {
backend.SkipDownloadItem(itemID, filePath)
}
+func (a *App) GetTrackISRC(spotifyTrackID string) string {
+ return backend.ResolveTrackISRC(spotifyTrackID)
+}
+
func (a *App) GetPreviewURL(trackID string) (string, error) {
return backend.GetPreviewURL(trackID)
}
diff --git a/backend/amazon.go b/backend/amazon.go
index a3484c5..679f16b 100644
--- a/backend/amazon.go
+++ b/backend/amazon.go
@@ -204,7 +204,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality str
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, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
+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 {
@@ -219,12 +219,14 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
filenameArtist = GetFirstArtist(spotifyArtistName)
filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist)
}
- expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false)
+ expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false, isrcOverride)
expectedPath := filepath.Join(outputDir, expectedFilename)
- 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
+ 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
+ }
}
}
@@ -250,12 +252,16 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
}
res.ISRC = isrc
if isrc != "" {
- 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")
+ if ShouldSkipMusicBrainzMetadataFetch() {
+ fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
} else {
- fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
+ 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
@@ -271,11 +277,13 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
return "", err
}
- var isrc string
+ isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
- isrc = result.ISRC
+ if isrc == "" {
+ isrc = result.ISRC
+ }
mbMeta = result.Metadata
}
@@ -309,6 +317,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
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))
@@ -346,6 +355,9 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
}
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)
@@ -390,7 +402,9 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
- Description: "https://github.com/afkarxyz/SpotiFLAC",
+ Composer: spotifyComposer,
+ Separator: metadataSeparator,
+ Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
Genre: mbMeta.Genre,
}
@@ -418,7 +432,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
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, spotifyURL string,
+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) {
@@ -427,5 +441,5 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, qualit
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, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre)
+ 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)
}
diff --git a/backend/artist_format.go b/backend/artist_format.go
new file mode 100644
index 0000000..29c4f26
--- /dev/null
+++ b/backend/artist_format.go
@@ -0,0 +1,90 @@
+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
+}
diff --git a/backend/config.go b/backend/config.go
index 5f1b562..e407cb3 100644
--- a/backend/config.go
+++ b/backend/config.go
@@ -50,23 +50,14 @@ func LoadConfigSettings() (map[string]interface{}, error) {
return settings, nil
}
-func GetSpotFetchAPISettings() (bool, string) {
+func GetRedownloadWithSuffixSetting() bool {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
- return false, ""
+ return false
}
- useAPI, _ := settings["useSpotFetchAPI"].(bool)
- if !useAPI {
- return false, ""
- }
-
- apiURL, _ := settings["spotFetchAPIUrl"].(string)
- if apiURL == "" {
- apiURL = "https://sp.afkarxyz.qzz.io/api"
- }
-
- return true, apiURL
+ enabled, _ := settings["redownloadWithSuffix"].(bool)
+ return enabled
}
func GetLinkResolverSetting() string {
diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go
index b9cd3e3..f228bb4 100644
--- a/backend/ffmpeg.go
+++ b/backend/ffmpeg.go
@@ -83,6 +83,37 @@ func GetFFmpegDir() (string, error) {
return EnsureAppDir()
}
+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 GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
@@ -94,38 +125,15 @@ func GetFFmpegPath() (string, error) {
ffmpegName = "ffmpeg.exe"
}
+ if path := resolveSystemExecutable(ffmpegName); path != "" {
+ return path, nil
+ }
+
localPath := filepath.Join(ffmpegDir, ffmpegName)
if _, err := os.Stat(localPath); err == nil {
return localPath, nil
}
- if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
- homebrewPath := "/opt/homebrew/bin/" + ffmpegName
- if _, err := os.Stat(homebrewPath); err == nil {
- return homebrewPath, nil
- }
- } else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
- homebrewPath := "/usr/local/bin/" + ffmpegName
- if _, err := os.Stat(homebrewPath); err == nil {
- return homebrewPath, nil
- }
- }
-
- if runtime.GOOS != "windows" {
- path, err := exec.Command("which", ffmpegName).Output()
- if err == nil {
- trimmed := strings.TrimSpace(string(path))
- if trimmed != "" {
- return trimmed, nil
- }
- }
- }
-
- path, err := exec.LookPath(ffmpegName)
- if err == nil {
- return path, nil
- }
-
return localPath, nil
}
@@ -140,38 +148,15 @@ func GetFFprobePath() (string, error) {
ffprobeName = "ffprobe.exe"
}
+ if path := resolveSystemExecutable(ffprobeName); path != "" {
+ return path, nil
+ }
+
localPath := filepath.Join(ffmpegDir, ffprobeName)
if _, err := os.Stat(localPath); err == nil {
return localPath, nil
}
- if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
- homebrewPath := "/opt/homebrew/bin/" + ffprobeName
- if _, err := os.Stat(homebrewPath); err == nil {
- return homebrewPath, nil
- }
- } else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
- homebrewPath := "/usr/local/bin/" + ffprobeName
- if _, err := os.Stat(homebrewPath); err == nil {
- return homebrewPath, nil
- }
- }
-
- if runtime.GOOS != "windows" {
- path, err := exec.Command("which", ffprobeName).Output()
- if err == nil {
- trimmed := strings.TrimSpace(string(path))
- if trimmed != "" {
- return trimmed, nil
- }
- }
- }
-
- path, err := exec.LookPath(ffprobeName)
- if err == nil {
- return path, nil
- }
-
return localPath, fmt.Errorf("ffprobe not found in app directory or system path")
}
@@ -205,7 +190,11 @@ func IsFFmpegInstalled() (bool, error) {
setHideWindow(cmd)
err = cmd.Run()
- return err == nil, nil
+ if err != nil {
+ return false, nil
+ }
+
+ return IsFFprobeInstalled()
}
func GetBrewPath() string {
@@ -255,10 +244,38 @@ func InstallFFmpegWithBrew(progressCallback func(int, string)) error {
return nil
}
-const (
- ffmpegWindowsURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-windows-amd64.zip"
- ffmpegLinuxURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-linux-amd64.tar.xz"
-)
+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 {
@@ -276,57 +293,30 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
}
- if runtime.GOOS == "darwin" {
- ffmpegInstalled, _ := IsFFmpegInstalled()
- ffprobeInstalled, _ := IsFFprobeInstalled()
+ ffmpegInstalled, _ := IsFFmpegInstalled()
+ ffprobeInstalled, _ := IsFFprobeInstalled()
- isARM := runtime.GOARCH == "arm64"
+ ffmpegURLs, ffprobeURLs, err := getFFmpegDownloadURLs()
+ if err != nil {
+ return err
+ }
- var macFFmpegURLs []string
- var macFFprobeURLs []string
-
- if isARM {
-
- macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-arm64.zip"}
- macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-arm64.zip"}
- } else {
-
- macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-intel.zip"}
- macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-intel.zip"}
+ if !ffmpegInstalled && !ffprobeInstalled {
+ if err := downloadWithFallback(ffmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil {
+ return err
}
-
- if !ffmpegInstalled && !ffprobeInstalled {
- if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil {
- return err
- }
- if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil {
- return err
- }
- } else if !ffmpegInstalled {
- if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
- return err
- }
- } else if !ffprobeInstalled {
- if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
- return err
- }
+ if err := downloadWithFallback(ffprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil {
+ return err
}
return nil
}
- var url string
- switch runtime.GOOS {
- case "windows":
- url = ffmpegWindowsURL
- case "linux":
- url = ffmpegLinuxURL
- default:
- return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
+ if !ffmpegInstalled {
+ return downloadWithFallback(ffmpegURLs, ffmpegDir, progressCallback, 0, 100)
}
- fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
- if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
- return err
+ if !ffprobeInstalled {
+ return downloadWithFallback(ffprobeURLs, ffmpegDir, progressCallback, 0, 100)
}
return nil
@@ -452,10 +442,13 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
}
fmt.Printf("[FFmpeg] Extracting...\n")
- if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
+ if strings.HasSuffix(url, ".tar.xz") {
return extractTarXz(tmpFile.Name(), destDir)
}
- return extractZip(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 {
diff --git a/backend/filemanager.go b/backend/filemanager.go
index 9b915fb..12f3b33 100644
--- a/backend/filemanager.go
+++ b/backend/filemanager.go
@@ -30,6 +30,8 @@ type AudioMetadata struct {
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 {
@@ -175,6 +177,12 @@ func readFlacMetadata(filePath string) (*AudioMetadata, error) {
}
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)
}
}
}
@@ -221,6 +229,28 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
}
}
+ 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
}
@@ -301,9 +331,13 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
if metadata.Year == "" || len(value) > len(metadata.Year) {
metadata.Year = value
}
+ case "isrc", "tsrc":
+ metadata.ISRC = value
}
}
+ metadata.UPC = firstPreferredFFprobeUPCValue(allTags)
+
return metadata, nil
}
@@ -333,6 +367,7 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
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))
diff --git a/backend/filename.go b/backend/filename.go
index 0c8a373..91ae94d 100644
--- a/backend/filename.go
+++ b/backend/filename.go
@@ -2,6 +2,7 @@ package backend
import (
"fmt"
+ "os"
"path/filepath"
"regexp"
"strings"
@@ -9,12 +10,12 @@ import (
"unicode/utf8"
)
-func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
-
+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)
@@ -36,6 +37,7 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
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))
@@ -67,7 +69,47 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
}
}
- return filename + ".flac"
+ 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 {
@@ -188,3 +230,10 @@ 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)
+}
diff --git a/backend/isrc_finder.go b/backend/isrc_finder.go
index cf077ea..4796c37 100644
--- a/backend/isrc_finder.go
+++ b/backend/isrc_finder.go
@@ -1,9 +1,6 @@
package backend
import (
- "crypto/hmac"
- "crypto/sha1"
- "encoding/binary"
"encoding/json"
"errors"
"fmt"
@@ -20,16 +17,10 @@ import (
)
const (
- spotifyServerTimeURL = "https://open.spotify.com/api/server-time"
- spotifySessionTokenURL = "https://open.spotify.com/api/token"
- spotifyTOTPSecretsURL = "https://git.gay/thereallo/totp-secrets/raw/branch/main/secrets/secretDict.json"
- spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token"
- spotifyTOTPPeriod = 30
- spotifyTOTPDigits = 6
- spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
- spotifyTokenCacheFile = ".isrc-finder-token.json"
- spotifySecretsCacheFile = "spotify-secret-dict-cache.json"
- spotifySecretsCacheTTL = 24 * time.Hour
+ 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
@@ -39,91 +30,104 @@ type spotifyAnonymousToken struct {
AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"`
}
-type spotifyServerTimeResponse struct {
- ServerTime int64 `json:"serverTime"`
-}
-
-type spotifySecretsCache struct {
- FetchedAtUnix int64 `json:"fetched_at_unix"`
- Secrets map[string][]int `json:"secrets"`
-}
-
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 spotFetchISRCResponse struct {
- Input string `json:"input"`
- TrackID string `json:"track_id"`
- GID string `json:"gid"`
- CanonicalURI string `json:"canonical_uri"`
- Name string `json:"name"`
- Artists []string `json:"artists"`
- AlbumName string `json:"album_name"`
- ReleaseDate string `json:"release_date"`
- Label string `json:"label"`
- ISRC string `json:"isrc"`
+type spotifyAlbumRawData struct {
+ ExternalID []struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ } `json:"external_id"`
}
-func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
+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 "", err
+ 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)
- return cachedISRC, nil
+ identifiers.ISRC = cachedISRC
}
- useSpotFetchAPI, spotFetchAPIURL := GetSpotFetchAPISettings()
- if useSpotFetchAPI {
- isrc, resolvedTrackID, err := s.lookupSpotifyISRCViaSpotFetchAPI(normalizedTrackID, spotFetchAPIURL)
- if err == nil && isrc != "" {
- fmt.Printf("Found ISRC via SpotFetch API: %s\n", isrc)
- cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc)
- return isrc, nil
- }
- if err != nil {
- fmt.Printf("Warning: SpotFetch ISRC lookup failed, falling back to Spotify metadata: %v\n", err)
- }
- }
+ httpClient := &http.Client{Timeout: 30 * time.Second}
- payload, metadataErr := fetchSpotifyTrackRawData(s.client, normalizedTrackID)
+ payload, metadataErr := fetchSpotifyTrackRawData(httpClient, normalizedTrackID)
if metadataErr == nil {
- isrc, extractErr := extractSpotifyTrackISRC(payload)
+ metadataIdentifiers, extractErr := extractSpotifyTrackIdentifiers(httpClient, payload)
if extractErr == nil {
- fmt.Printf("Found ISRC via Spotify metadata: %s\n", isrc)
- cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", isrc)
- return isrc, 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 ISRC lookup failed, falling back to Soundplate: %v\n", metadataErr)
+ fmt.Printf("Warning: Spotify metadata identifier lookup failed, falling back to Soundplate: %v\n", metadataErr)
}
- isrc, resolvedTrackID, soundplateErr := s.lookupSpotifyISRCViaSoundplate(normalizedTrackID)
- if soundplateErr == nil && isrc != "" {
- fmt.Printf("Found ISRC via Soundplate: %s\n", isrc)
- cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc)
- return isrc, nil
+ 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 metadataErr != nil && soundplateErr != nil {
- return "", fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr)
+ if identifiers.ISRC != "" || identifiers.UPC != "" {
+ return identifiers, nil
}
- if soundplateErr != nil {
- return "", soundplateErr
+ if metadataErr != nil {
+ return identifiers, metadataErr
}
- return "", 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) {
@@ -137,47 +141,28 @@ func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc
}
}
-func (s *SongLinkClient) lookupSpotifyISRCViaSpotFetchAPI(spotifyTrackID string, apiBaseURL string) (string, string, error) {
- normalizedTrackID := strings.TrimSpace(spotifyTrackID)
- baseURL := strings.TrimRight(strings.TrimSpace(apiBaseURL), "/")
- if normalizedTrackID == "" {
- return "", "", fmt.Errorf("spotify track ID is required")
+func mergeSpotifyTrackIdentifiers(target *SpotifyTrackIdentifiers, incoming SpotifyTrackIdentifiers) {
+ if incoming.ISRC != "" {
+ target.ISRC = strings.TrimSpace(incoming.ISRC)
}
- if baseURL == "" {
- return "", "", fmt.Errorf("spotfetch api url is required")
+ 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")
}
- requestURL := fmt.Sprintf("%s/isrc/%s", baseURL, url.PathEscape(normalizedTrackID))
- req, err := http.NewRequest(http.MethodGet, requestURL, nil)
+ httpClient := &http.Client{Timeout: 30 * time.Second}
+ payload, err := fetchSpotifyAlbumRawData(httpClient, normalizedAlbumID)
if err != nil {
- return "", "", fmt.Errorf("failed to create SpotFetch ISRC request: %w", err)
- }
- req.Header.Set("User-Agent", songLinkUserAgent)
- req.Header.Set("Accept", "application/json")
-
- client := &http.Client{Timeout: 15 * time.Second}
- resp, err := client.Do(req)
- if err != nil {
- return "", "", fmt.Errorf("SpotFetch ISRC request failed: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
- return "", "", fmt.Errorf("SpotFetch ISRC returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview)))
+ return "", err
}
- var payload spotFetchISRCResponse
- if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
- return "", "", fmt.Errorf("failed to decode SpotFetch ISRC response: %w", err)
- }
-
- isrc := firstISRCMatch(payload.ISRC)
- if isrc == "" {
- return "", "", fmt.Errorf("ISRC missing in SpotFetch response")
- }
-
- return isrc, strings.TrimSpace(payload.TrackID), nil
+ return extractSpotifyAlbumUPC(payload)
}
func requestSpotifyBytes(client *http.Client, targetURL string, headers map[string]string) ([]byte, error) {
@@ -269,50 +254,6 @@ func saveSpotifyCachedToken(token *spotifyAnonymousToken) error {
return nil
}
-func loadSpotifyCachedSecrets() (*spotifySecretsCache, error) {
- cachePath, err := spotifySecretsCachePath()
- 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 secrets cache: %w", err)
- }
-
- var cache spotifySecretsCache
- if err := json.Unmarshal(body, &cache); err != nil {
- return nil, fmt.Errorf("failed to parse secrets cache: %w", err)
- }
-
- return &cache, nil
-}
-
-func saveSpotifyCachedSecrets(cache *spotifySecretsCache) error {
- cachePath, err := spotifySecretsCachePath()
- if err != nil {
- return err
- }
-
- if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
- return fmt.Errorf("failed to create secrets cache directory: %w", err)
- }
-
- body, err := json.MarshalIndent(cache, "", " ")
- if err != nil {
- return err
- }
-
- if err := os.WriteFile(cachePath, body, 0o644); err != nil {
- return fmt.Errorf("failed to write secrets cache: %w", err)
- }
-
- return nil
-}
-
func spotifyTokenCachePath() (string, error) {
appDir, err := EnsureAppDir()
if err != nil {
@@ -322,15 +263,6 @@ func spotifyTokenCachePath() (string, error) {
return filepath.Join(appDir, spotifyTokenCacheFile), nil
}
-func spotifySecretsCachePath() (string, error) {
- appDir, err := EnsureAppDir()
- if err != nil {
- return "", err
- }
-
- return filepath.Join(appDir, spotifySecretsCacheFile), nil
-}
-
func spotifyTokenIsValid(token *spotifyAnonymousToken) bool {
if token == nil || token.AccessToken == "" || token.AccessTokenExpirationTimestampMs == 0 {
return false
@@ -339,47 +271,6 @@ func spotifyTokenIsValid(token *spotifyAnonymousToken) bool {
return time.Now().UnixMilli() < token.AccessTokenExpirationTimestampMs-30_000
}
-func spotifySecretsCacheIsValid(cache *spotifySecretsCache) bool {
- if cache == nil || cache.FetchedAtUnix == 0 || len(cache.Secrets) == 0 {
- return false
- }
-
- return time.Since(time.Unix(cache.FetchedAtUnix, 0)) < spotifySecretsCacheTTL
-}
-
-func deriveSpotifyTOTPSecret(ciphertext []int) []byte {
- var builder strings.Builder
-
- for index, value := range ciphertext {
- builder.WriteString(strconv.Itoa(value ^ ((index % 33) + 9)))
- }
-
- return []byte(builder.String())
-}
-
-func generateSpotifyTOTP(secret []byte, timestampMs int64) string {
- counter := timestampMs / 1000 / spotifyTOTPPeriod
- counterBytes := make([]byte, 8)
- binary.BigEndian.PutUint64(counterBytes, uint64(counter))
-
- mac := hmac.New(sha1.New, secret)
- mac.Write(counterBytes)
- digest := mac.Sum(nil)
-
- offset := digest[len(digest)-1] & 0x0f
- binaryCode := (int(digest[offset])&0x7f)<<24 |
- (int(digest[offset+1])&0xff)<<16 |
- (int(digest[offset+2])&0xff)<<8 |
- (int(digest[offset+3]) & 0xff)
-
- modulo := 1
- for i := 0; i < spotifyTOTPDigits; i++ {
- modulo *= 10
- }
-
- return fmt.Sprintf("%0*d", spotifyTOTPDigits, binaryCode%modulo)
-}
-
func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
spotifyAnonymousTokenMu.Lock()
defer spotifyAnonymousTokenMu.Unlock()
@@ -393,52 +284,17 @@ func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
return cachedToken.AccessToken, nil
}
- var serverTime spotifyServerTimeResponse
- if err := requestSpotifyJSON(client, spotifyServerTimeURL, nil, &serverTime); err != nil {
- return "", err
- }
-
- var secrets map[string][]int
- cachedSecrets, err := loadSpotifyCachedSecrets()
+ generatedTOTP, version, err := generateSpotifyTOTP(time.Now())
if err != nil {
- fmt.Printf("Warning: failed to read Spotify secrets cache: %v\n", err)
+ return "", fmt.Errorf("failed to generate Spotify TOTP: %w", err)
}
- if spotifySecretsCacheIsValid(cachedSecrets) {
- secrets = cachedSecrets.Secrets
- } else {
- if err := requestSpotifyJSON(client, spotifyTOTPSecretsURL, nil, &secrets); err != nil {
- if cachedSecrets != nil && len(cachedSecrets.Secrets) > 0 {
- fmt.Printf("Warning: failed to refresh Spotify secrets cache, using stale cache: %v\n", err)
- secrets = cachedSecrets.Secrets
- } else {
- return "", err
- }
- } else {
- cache := &spotifySecretsCache{
- FetchedAtUnix: time.Now().Unix(),
- Secrets: secrets,
- }
- if err := saveSpotifyCachedSecrets(cache); err != nil {
- fmt.Printf("Warning: failed to write Spotify secrets cache: %v\n", err)
- }
- }
- }
-
- version, err := latestSpotifySecretVersion(secrets)
- if err != nil {
- return "", err
- }
-
- secret := deriveSpotifyTOTPSecret(secrets[version])
- generatedTOTP := generateSpotifyTOTP(secret, serverTime.ServerTime*1000)
-
query := url.Values{
"reason": {"init"},
"productType": {"web-player"},
"totp": {generatedTOTP},
"totpServer": {generatedTOTP},
- "totpVer": {version},
+ "totpVer": {strconv.Itoa(version)},
}
var token spotifyAnonymousToken
@@ -453,30 +309,6 @@ func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
return token.AccessToken, nil
}
-func latestSpotifySecretVersion(secrets map[string][]int) (string, error) {
- var (
- bestVersion string
- bestNumber int
- )
-
- for version := range secrets {
- number, err := strconv.Atoi(version)
- if err != nil {
- return "", fmt.Errorf("invalid secret version %q: %w", version, err)
- }
- if bestVersion == "" || number > bestNumber {
- bestVersion = version
- bestNumber = number
- }
- }
-
- if bestVersion == "" {
- return "", errors.New("no TOTP secret versions available")
- }
-
- return bestVersion, nil
-}
-
func extractSpotifyTrackID(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
@@ -504,14 +336,18 @@ func extractSpotifyTrackID(value string) (string, error) {
}
func spotifyTrackIDToGID(trackID string) (string, error) {
- if trackID == "" {
- return "", errors.New("track ID is empty")
+ 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 trackID {
+ for _, char := range entityID {
index := strings.IndexRune(spotifyBase62Alphabet, char)
if index < 0 {
return "", fmt.Errorf("invalid base62 character: %q", string(char))
@@ -530,43 +366,99 @@ func spotifyTrackIDToGID(trackID string) (string, error) {
}
func fetchSpotifyTrackRawData(client *http.Client, trackID string) ([]byte, error) {
- accessToken, err := requestSpotifyAnonymousAccessToken(client)
+ gid, err := spotifyTrackIDToGID(trackID)
if err != nil {
return nil, err
}
- gid, err := spotifyTrackIDToGID(trackID)
+ 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, "track", gid),
+ fmt.Sprintf(spotifyGIDMetadataURL, entityType, gid),
map[string]string{
"authorization": "Bearer " + accessToken,
"accept": "application/json",
+ "user-agent": songLinkUserAgent,
},
)
}
-func extractSpotifyTrackISRC(payload []byte) (string, error) {
+func extractSpotifyTrackIdentifiers(client *http.Client, payload []byte) (SpotifyTrackIdentifiers, error) {
var track spotifyTrackRawData
if err := json.Unmarshal(payload, &track); err != nil {
- return "", fmt.Errorf("failed to decode Spotify track metadata: %w", err)
+ 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 != "" {
- return isrc, nil
+ identifiers.ISRC = isrc
+ break
}
}
}
- if fallbackISRC := firstISRCMatch(string(payload)); fallbackISRC != "" {
- return fallbackISRC, nil
+ 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")
+}
diff --git a/backend/isrc_helper.go b/backend/isrc_helper.go
new file mode 100644
index 0000000..2f8283b
--- /dev/null
+++ b/backend/isrc_helper.go
@@ -0,0 +1,22 @@
+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))
+}
diff --git a/backend/lyrics.go b/backend/lyrics.go
index 3feba0d..16e025a 100644
--- a/backend/lyrics.go
+++ b/backend/lyrics.go
@@ -44,6 +44,7 @@ type LyricsDownloadRequest struct {
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"`
@@ -363,11 +364,12 @@ func msToLRCTimestamp(msStr string) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
}
-func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
+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 {
@@ -384,6 +386,7 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
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))
@@ -485,10 +488,15 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
if filenameFormat == "" {
filenameFormat = "title-artist"
}
- filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
+ 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)
- if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
+ filePath, alreadyExists := ResolveOutputPathForDownload(filePath, GetRedownloadWithSuffixSetting())
+ if alreadyExists {
return &LyricsDownloadResponse{
Success: true,
Message: "Lyrics file already exists",
diff --git a/backend/metadata.go b/backend/metadata.go
index 50cd52d..f438338 100644
--- a/backend/metadata.go
+++ b/backend/metadata.go
@@ -21,6 +21,7 @@ type Metadata struct {
Artist string
Album string
AlbumArtist string
+ Separator string
Date string
ReleaseDate string
TrackNumber int
@@ -31,12 +32,73 @@ type Metadata struct {
Comment string
Copyright string
Publisher string
+ Composer string
Lyrics string
Description string
ISRC string
+ UPC string
Genre string
}
+func resolveMetadataSeparator(separator string) string {
+ if normalized := normalizeArtistSeparator(separator); normalized != "" {
+ return normalized
+ }
+
+ return normalizeArtistSeparator(GetSeparator())
+}
+
+func displayMetadataSeparator(separator string) string {
+ if resolved := resolveMetadataSeparator(separator); resolved != "" {
+ return resolved + " "
+ }
+
+ return "; "
+}
+
+func addVorbisTagValues(cmt *flacvorbis.MetaDataBlockVorbisComment, key string, values []string) {
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ continue
+ }
+
+ _ = cmt.Add(key, value)
+ }
+}
+
+func addMP3TextFrame(tag *id3v2.Tag, frameID string, value string) {
+ tag.DeleteFrames(frameID)
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return
+ }
+
+ tag.AddTextFrame(frameID, id3v2.EncodingUTF8, value)
+}
+
+func joinMultiValueText(values []string, separator string, nullSeparated bool) string {
+ cleaned := make([]string, 0, len(values))
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value != "" {
+ cleaned = append(cleaned, value)
+ }
+ }
+
+ if len(cleaned) == 0 {
+ return ""
+ }
+ if len(cleaned) == 1 {
+ return cleaned[0]
+ }
+ if nullSeparated {
+ return strings.Join(cleaned, "\x00")
+ }
+
+ return strings.Join(cleaned, displayMetadataSeparator(separator))
+}
+
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
f, err := flac.ParseFile(filepath)
if err != nil {
@@ -52,17 +114,22 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
}
cmt := flacvorbis.New()
+ separator := resolveMetadataSeparator(metadata.Separator)
if metadata.Title != "" {
_ = cmt.Add(flacvorbis.FIELD_TITLE, metadata.Title)
}
- if metadata.Artist != "" {
+ if artistValues := SplitArtistCredits(metadata.Artist, separator); len(artistValues) > 0 {
+ addVorbisTagValues(cmt, flacvorbis.FIELD_ARTIST, artistValues)
+ } else if metadata.Artist != "" {
_ = cmt.Add(flacvorbis.FIELD_ARTIST, metadata.Artist)
}
if metadata.Album != "" {
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
}
- if metadata.AlbumArtist != "" {
+ if albumArtistValues := SplitArtistCredits(metadata.AlbumArtist, separator); len(albumArtistValues) > 0 {
+ addVorbisTagValues(cmt, "ALBUMARTIST", albumArtistValues)
+ } else if metadata.AlbumArtist != "" {
_ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist)
}
if metadata.Date != "" {
@@ -86,6 +153,11 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
if metadata.Publisher != "" {
_ = cmt.Add("PUBLISHER", metadata.Publisher)
}
+ if composerValues := SplitArtistCredits(metadata.Composer, separator); len(composerValues) > 0 {
+ addVorbisTagValues(cmt, "COMPOSER", composerValues)
+ } else if metadata.Composer != "" {
+ _ = cmt.Add("COMPOSER", metadata.Composer)
+ }
if metadata.Description != "" {
_ = cmt.Add("DESCRIPTION", metadata.Description)
}
@@ -96,8 +168,13 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
if metadata.ISRC != "" {
_ = cmt.Add("ISRC", metadata.ISRC)
}
+ if metadata.UPC != "" {
+ _ = cmt.Add(preferredUPCTagKey, metadata.UPC)
+ }
- if metadata.Genre != "" {
+ if genreValues := SplitMetadataValues(metadata.Genre, separator); len(genreValues) > 0 {
+ addVorbisTagValues(cmt, "GENRE", genreValues)
+ } else if metadata.Genre != "" {
_ = cmt.Add("GENRE", metadata.Genre)
}
@@ -901,8 +978,14 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
metadata.Copyright = value
case "publisher", "tpub", "label":
metadata.Publisher = value
+ case "composer", "writer", "wm/composer", "©wrt":
+ metadata.Composer = value
+ case "genre", "tcon":
+ metadata.Genre = value
case "url":
metadata.URL = value
+ case "isrc", "tsrc":
+ metadata.ISRC = value
case "comment", "comments":
if metadata.Comment == "" {
metadata.Comment = value
@@ -914,6 +997,8 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
}
}
+ metadata.UPC = firstPreferredFFprobeUPCValue(allTags)
+
return metadata, nil
}
@@ -940,15 +1025,13 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
+ separator := resolveMetadataSeparator(metadata.Separator)
tag.DeleteFrames("TXXX")
if metadata.Title != "" {
tag.SetTitle(metadata.Title)
}
- if metadata.Artist != "" {
- tag.SetArtist(metadata.Artist)
- }
if metadata.Album != "" {
tag.SetAlbum(metadata.Album)
}
@@ -960,10 +1043,17 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er
tag.SetYear(year)
}
- if metadata.AlbumArtist != "" {
- tag.DeleteFrames("TPE2")
- tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, metadata.AlbumArtist)
+ artistText := joinMultiValueText(SplitArtistCredits(metadata.Artist, separator), separator, true)
+ if artistText == "" {
+ artistText = strings.TrimSpace(metadata.Artist)
}
+ addMP3TextFrame(tag, "TPE1", artistText)
+
+ albumArtistText := joinMultiValueText(SplitArtistCredits(metadata.AlbumArtist, separator), separator, true)
+ if albumArtistText == "" {
+ albumArtistText = strings.TrimSpace(metadata.AlbumArtist)
+ }
+ addMP3TextFrame(tag, "TPE2", albumArtistText)
if metadata.TrackNumber > 0 {
tag.DeleteFrames(tag.CommonID("Track number/Position in set"))
@@ -984,18 +1074,28 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er
}
if metadata.Copyright != "" {
- tag.DeleteFrames("TCOP")
- tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, metadata.Copyright)
+ addMP3TextFrame(tag, "TCOP", metadata.Copyright)
}
if metadata.Publisher != "" {
- tag.DeleteFrames("TPUB")
- tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
+ addMP3TextFrame(tag, "TPUB", metadata.Publisher)
}
+ composerText := joinMultiValueText(SplitArtistCredits(metadata.Composer, separator), separator, true)
+ if composerText == "" {
+ composerText = strings.TrimSpace(metadata.Composer)
+ }
+ addMP3TextFrame(tag, "TCOM", composerText)
+
if metadata.ISRC != "" {
- tag.DeleteFrames("TSRC")
- tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC)
+ addMP3TextFrame(tag, "TSRC", metadata.ISRC)
+ }
+ if metadata.UPC != "" {
+ tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{
+ Encoding: id3v2.EncodingUTF8,
+ Description: "UPC",
+ Value: metadata.UPC,
+ })
}
if comment := resolveMetadataComment(metadata); comment != "" {
@@ -1027,6 +1127,12 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er
}
}
+ genreText := joinMultiValueText(SplitMetadataValues(metadata.Genre, separator), separator, true)
+ if genreText == "" {
+ genreText = strings.TrimSpace(metadata.Genre)
+ }
+ addMP3TextFrame(tag, "TCON", genreText)
+
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
@@ -1048,6 +1154,7 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er
"-i", filePath,
"-y",
}
+ separator := resolveMetadataSeparator(metadata.Separator)
if coverPath != "" && fileExists(coverPath) {
args = append(args, "-i", coverPath)
@@ -1059,14 +1166,22 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er
if metadata.Title != "" {
args = append(args, "-metadata", "title="+metadata.Title)
}
- if metadata.Artist != "" {
- args = append(args, "-metadata", "artist="+metadata.Artist)
+ artistText := joinMultiValueText(SplitArtistCredits(metadata.Artist, separator), separator, false)
+ if artistText == "" {
+ artistText = strings.TrimSpace(metadata.Artist)
+ }
+ if artistText != "" {
+ args = append(args, "-metadata", "artist="+artistText)
}
if metadata.Album != "" {
args = append(args, "-metadata", "album="+metadata.Album)
}
- if metadata.AlbumArtist != "" {
- args = append(args, "-metadata", "album_artist="+metadata.AlbumArtist)
+ albumArtistText := joinMultiValueText(SplitArtistCredits(metadata.AlbumArtist, separator), separator, false)
+ if albumArtistText == "" {
+ albumArtistText = strings.TrimSpace(metadata.AlbumArtist)
+ }
+ if albumArtistText != "" {
+ args = append(args, "-metadata", "album_artist="+albumArtistText)
}
if metadata.Date != "" {
args = append(args, "-metadata", "date="+metadata.Date)
@@ -1091,9 +1206,26 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er
if metadata.Publisher != "" {
args = append(args, "-metadata", "publisher="+metadata.Publisher)
}
+ composerText := joinMultiValueText(SplitArtistCredits(metadata.Composer, separator), separator, false)
+ if composerText == "" {
+ composerText = strings.TrimSpace(metadata.Composer)
+ }
+ if composerText != "" {
+ args = append(args, "-metadata", "composer="+composerText)
+ }
if metadata.ISRC != "" {
args = append(args, "-metadata", "isrc="+metadata.ISRC)
}
+ if metadata.UPC != "" {
+ args = append(args, "-metadata", "upc="+metadata.UPC)
+ }
+ genreText := joinMultiValueText(SplitMetadataValues(metadata.Genre, separator), separator, false)
+ if genreText == "" {
+ genreText = strings.TrimSpace(metadata.Genre)
+ }
+ if genreText != "" {
+ args = append(args, "-metadata", "genre="+genreText)
+ }
if comment := resolveMetadataComment(metadata); comment != "" {
args = append(args, "-metadata", "comment="+comment)
}
diff --git a/backend/musicbrainz.go b/backend/musicbrainz.go
index 2cca4af..81cb2f9 100644
--- a/backend/musicbrainz.go
+++ b/backend/musicbrainz.go
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strings"
+ "sync"
"time"
"golang.org/x/text/cases"
@@ -14,7 +15,66 @@ import (
var AppVersion = "Unknown"
-const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
+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 {
@@ -54,66 +114,176 @@ type MusicBrainzRecordingResponse struct {
} `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 ( hi@afkarxyz.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 == "" {
- return meta, fmt.Errorf("no ISRC provided")
+ 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: 10 * time.Second,
+ Timeout: musicBrainzRequestTimeout,
}
query := fmt.Sprintf("isrc:%s", isrc)
- 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("GET", reqURL, nil)
+ mbResp, err := queryMusicBrainzRecordings(client, query)
if err != nil {
- return meta, err
- }
-
- req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( hi@afkarxyz.qzz.io )", AppVersion))
-
- var resp *http.Response
- var lastErr error
-
- for i := 0; i < 3; i++ {
- resp, lastErr = client.Do(req)
- if lastErr == nil && resp.StatusCode == http.StatusOK {
- break
- }
-
- if resp != nil {
- resp.Body.Close()
- }
-
- if i < 2 {
- time.Sleep(2 * time.Second)
- }
- }
-
- if lastErr != nil {
- return meta, lastErr
- }
-
- if resp.StatusCode != http.StatusOK {
- resp.Body.Close()
- return meta, fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
- }
- defer resp.Body.Close()
-
- var mbResp MusicBrainzRecordingResponse
- if err := json.NewDecoder(resp.Body).Decode(&mbResp); err != nil {
- return meta, err
+ resultErr = err
+ return meta, resultErr
}
if len(mbResp.Recordings) == 0 {
- return meta, fmt.Errorf("no recordings found for ISRC: %s", isrc)
+ resultErr = fmt.Errorf("no recordings found for ISRC: %s", isrc)
+ return meta, resultErr
}
recording := mbResp.Recordings[0]
@@ -150,5 +320,12 @@ func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre
}
}
+ if meta.Genre == "" {
+ resultErr = fmt.Errorf("no genre tags found in MusicBrainz")
+ return meta, resultErr
+ }
+
+ musicBrainzCache.Store(cacheKey, meta)
+
return meta, nil
}
diff --git a/backend/qobuz.go b/backend/qobuz.go
index 0117a2b..ba1d36b 100644
--- a/backend/qobuz.go
+++ b/backend/qobuz.go
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
"os"
"path/filepath"
"regexp"
@@ -72,21 +73,41 @@ func NewQobuzDownloader() *QobuzDownloader {
client: &http.Client{
Timeout: 60 * time.Second,
},
- appID: "798273057",
+ appID: qobuzDefaultAPIAppID,
}
}
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
- apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query="
- url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID)
+ if strings.HasPrefix(isrc, "qobuz_") {
+ trackID := strings.TrimPrefix(isrc, "qobuz_")
+ resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch track: %w", err)
+ }
+ defer resp.Body.Close()
- resp, err := q.client.Get(url)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
+ }
+
+ var trackResp QobuzTrack
+ if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &trackResp, nil
+ }
+
+ resp, err := doQobuzSignedRequest(http.MethodGet, "track/search", url.Values{
+ "query": {isrc},
+ "limit": {"1"},
+ }, q.client)
if err != nil {
return nil, fmt.Errorf("failed to search track: %w", err)
}
defer resp.Body.Close()
- if resp.StatusCode != 200 {
+ if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
@@ -305,8 +326,12 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
return err
}
-func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
+func buildQobuzFilename(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 {
@@ -326,6 +351,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
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))
@@ -360,7 +386,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
return filename + ".flac"
}
-func (q *QobuzDownloader) DownloadTrack(spotifyID, 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, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
+func (q *QobuzDownloader) DownloadTrack(spotifyID, 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, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
var isrc string
if spotifyID != "" {
linkClient := NewSongLinkClient()
@@ -373,22 +399,27 @@ func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameF
return "", fmt.Errorf("spotify ID is required for Qobuz download")
}
- return q.DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
+ return q.DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
}
-func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, 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, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
+func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, 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, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
metaChan := make(chan Metadata, 1)
if embedGenre && isrc != "" {
go func() {
- fmt.Println("Fetching MusicBrainz metadata...")
- if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
- fmt.Println("✓ MusicBrainz metadata fetched")
- metaChan <- fetchedMeta
- } else {
- fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
+ if ShouldSkipMusicBrainzMetadataFetch() {
+ fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
metaChan <- Metadata{}
+ } else {
+ fmt.Println("Fetching MusicBrainz metadata...")
+ if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
+ fmt.Println("✓ MusicBrainz metadata fetched")
+ metaChan <- fetchedMeta
+ } else {
+ fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
+ metaChan <- Metadata{}
+ }
}
}()
} else {
@@ -446,11 +477,11 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
safeTitle := sanitizeFilename(trackTitle)
safeAlbum := sanitizeFilename(albumTitle)
- filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
+ filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrc)
filepath := filepath.Join(outputDir, filename)
-
- if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
- fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(fileInfo.Size())/(1024*1024))
+ filepath, alreadyExists := ResolveOutputPathForDownload(filepath, GetRedownloadWithSuffixSetting())
+ if alreadyExists {
+ fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(mustFileSize(filepath))/(1024*1024))
return "EXISTS:" + filepath, nil
}
@@ -487,6 +518,14 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
trackNumberToEmbed = 1
}
+ upc := ""
+ 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)
+ }
+
metadata := Metadata{
Title: trackTitle,
Artist: artists,
@@ -501,8 +540,11 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
- Description: "https://github.com/afkarxyz/SpotiFLAC",
+ Composer: spotifyComposer,
+ Separator: metadataSeparator,
+ Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
+ UPC: upc,
Genre: mbMeta.Genre,
}
diff --git a/backend/qobuz_api.go b/backend/qobuz_api.go
new file mode 100644
index 0000000..de5bfb9
--- /dev/null
+++ b/backend/qobuz_api.go
@@ -0,0 +1,407 @@
+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 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
+ 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(`