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 [![Announcements](https://img.shields.io/badge/ANNOUNCEMENTS-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac) [![Chat](https://img.shields.io/badge/CHAT-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat) -### [Download](https://github.com/afkarxyz/SpotiFLAC/releases) +### [Download](https://github.com/spotbye/SpotiFLAC/releases) ![Image](https://github.com/user-attachments/assets/c2624ca5-8569-49f0-950e-4410b523cea1) ## 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(`]+src="([^"]+/js/main\.js|/resources/[^"]+/js/main\.js)"`) + qobuzOpenAPIConfigPattern = regexp.MustCompile(`app_id:"(?P\d{9})",app_secret:"(?P[a-f0-9]{32})"`) +) + +type qobuzAPICredentials struct { + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + Source string `json:"source,omitempty"` + FetchedAtUnix int64 `json:"fetched_at_unix"` +} + +type qobuzCredentialProbeResponse struct { + Tracks struct { + Total int `json:"total"` + } `json:"tracks"` +} + +func defaultQobuzAPICredentials() *qobuzAPICredentials { + return &qobuzAPICredentials{ + AppID: qobuzDefaultAPIAppID, + AppSecret: qobuzDefaultAPIAppSecret, + Source: "embedded-default", + FetchedAtUnix: time.Now().Unix(), + } +} + +func qobuzCredentialsCachePath() (string, error) { + appDir, err := GetFFmpegDir() + if err != nil { + return "", err + } + return filepath.Join(appDir, qobuzCredentialsCacheFile), nil +} + +func loadQobuzCachedCredentials() (*qobuzAPICredentials, error) { + cachePath, err := qobuzCredentialsCachePath() + if err != nil { + return nil, err + } + + body, err := os.ReadFile(cachePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read qobuz credentials cache: %w", err) + } + + var creds qobuzAPICredentials + if err := json.Unmarshal(body, &creds); err != nil { + return nil, fmt.Errorf("failed to parse qobuz credentials cache: %w", err) + } + + if strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" { + return nil, fmt.Errorf("qobuz credentials cache is incomplete") + } + + return &creds, nil +} + +func saveQobuzCachedCredentials(creds *qobuzAPICredentials) error { + if creds == nil { + return fmt.Errorf("qobuz credentials are required") + } + + cachePath, err := qobuzCredentialsCachePath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + return fmt.Errorf("failed to create qobuz credentials cache directory: %w", err) + } + + body, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return err + } + + if err := os.WriteFile(cachePath, body, 0o644); err != nil { + return fmt.Errorf("failed to write qobuz credentials cache: %w", err) + } + + return nil +} + +func qobuzCredentialsCacheIsFresh(creds *qobuzAPICredentials) bool { + if creds == nil || creds.FetchedAtUnix == 0 || strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" { + return false + } + return time.Since(time.Unix(creds.FetchedAtUnix, 0)) < qobuzCredentialsCacheTTL +} + +func scrapeQobuzOpenCredentials(client *http.Client) (*qobuzAPICredentials, error) { + req, err := http.NewRequest(http.MethodGet, qobuzOpenTrackProbeURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", qobuzDefaultUA) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch open.qobuz.com shell: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + preview, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("open.qobuz.com returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(preview))) + } + + htmlBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read open.qobuz.com shell: %w", err) + } + + scriptMatch := qobuzOpenBundleScriptPattern.FindStringSubmatch(string(htmlBody)) + if len(scriptMatch) < 2 { + return nil, fmt.Errorf("qobuz open bundle URL not found") + } + + bundleURL := strings.TrimSpace(scriptMatch[1]) + if strings.HasPrefix(bundleURL, "/") { + bundleURL = "https://open.qobuz.com" + bundleURL + } + if bundleURL == "" { + return nil, fmt.Errorf("qobuz open bundle URL is empty") + } + + bundleReq, err := http.NewRequest(http.MethodGet, bundleURL, nil) + if err != nil { + return nil, err + } + bundleReq.Header.Set("User-Agent", qobuzDefaultUA) + + bundleResp, err := client.Do(bundleReq) + if err != nil { + return nil, fmt.Errorf("failed to fetch qobuz open bundle: %w", err) + } + defer bundleResp.Body.Close() + + if bundleResp.StatusCode != http.StatusOK { + preview, _ := io.ReadAll(io.LimitReader(bundleResp.Body, 512)) + return nil, fmt.Errorf("qobuz open bundle returned status %d: %s", bundleResp.StatusCode, strings.TrimSpace(string(preview))) + } + + bundleBody, err := io.ReadAll(bundleResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read qobuz open bundle: %w", err) + } + + configMatch := qobuzOpenAPIConfigPattern.FindStringSubmatch(string(bundleBody)) + if len(configMatch) < 3 { + return nil, fmt.Errorf("qobuz api app_id/app_secret pair not found in open bundle") + } + + return &qobuzAPICredentials{ + AppID: strings.TrimSpace(configMatch[1]), + AppSecret: strings.TrimSpace(configMatch[2]), + Source: bundleURL, + FetchedAtUnix: time.Now().Unix(), + }, nil +} + +func qobuzNormalizedPath(path string) string { + return strings.Trim(strings.TrimSpace(path), "/") +} + +func qobuzSignaturePayload(path string, params url.Values, timestamp string, secret string) string { + normalizedPath := strings.ReplaceAll(qobuzNormalizedPath(path), "/", "") + keys := make([]string, 0, len(params)) + for key := range params { + switch key { + case "app_id", "request_ts", "request_sig": + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + + var builder strings.Builder + builder.WriteString(normalizedPath) + for _, key := range keys { + values := params[key] + if len(values) == 0 { + builder.WriteString(key) + continue + } + for _, value := range values { + builder.WriteString(key) + builder.WriteString(value) + } + } + builder.WriteString(timestamp) + builder.WriteString(secret) + return builder.String() +} + +func qobuzRequestSignature(path string, params url.Values, timestamp string, secret string) string { + sum := md5.Sum([]byte(qobuzSignaturePayload(path, params, timestamp, secret))) + return hex.EncodeToString(sum[:]) +} + +func newQobuzSignedRequestWithCredentials(method string, path string, params url.Values, creds *qobuzAPICredentials) (*http.Request, error) { + normalizedPath := qobuzNormalizedPath(path) + if normalizedPath == "" { + return nil, fmt.Errorf("qobuz request path is empty") + } + if creds == nil || strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" { + return nil, fmt.Errorf("qobuz credentials are incomplete") + } + + clonedParams := url.Values{} + for key, values := range params { + for _, value := range values { + clonedParams.Add(key, value) + } + } + + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + clonedParams.Set("app_id", creds.AppID) + clonedParams.Set("request_ts", timestamp) + clonedParams.Set("request_sig", qobuzRequestSignature(normalizedPath, params, timestamp, creds.AppSecret)) + + reqURL := fmt.Sprintf("%s/%s?%s", qobuzAPIBaseURL, normalizedPath, clonedParams.Encode()) + req, err := http.NewRequest(method, reqURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", qobuzDefaultUA) + req.Header.Set("Accept", "application/json") + req.Header.Set("X-App-Id", creds.AppID) + + return req, nil +} + +func qobuzCredentialsSupportSignedMetadata(client *http.Client, creds *qobuzAPICredentials) bool { + if creds == nil { + return false + } + + req, err := newQobuzSignedRequestWithCredentials(http.MethodGet, "track/search", url.Values{ + "query": {qobuzCredentialsProbeTrackISRC}, + "limit": {"1"}, + }, creds) + if err != nil { + return false + } + + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false + } + + var payload qobuzCredentialProbeResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return false + } + + return payload.Tracks.Total > 0 +} + +func getQobuzAPICredentials(forceRefresh bool) (*qobuzAPICredentials, error) { + qobuzCredentialsMu.Lock() + defer qobuzCredentialsMu.Unlock() + + if !forceRefresh && qobuzCredentialsCacheIsFresh(qobuzCachedCredentials) { + return qobuzCachedCredentials, nil + } + + cachedFromDisk, diskErr := loadQobuzCachedCredentials() + if diskErr != nil { + fmt.Printf("Warning: failed to read Qobuz credentials cache: %v\n", diskErr) + } + if !forceRefresh && qobuzCredentialsCacheIsFresh(cachedFromDisk) { + qobuzCachedCredentials = cachedFromDisk + return qobuzCachedCredentials, nil + } + + client := &http.Client{Timeout: 30 * time.Second} + scrapedCreds, scrapeErr := scrapeQobuzOpenCredentials(client) + if scrapeErr == nil { + if qobuzCredentialsSupportSignedMetadata(client, scrapedCreds) { + qobuzCachedCredentials = scrapedCreds + if err := saveQobuzCachedCredentials(scrapedCreds); err != nil { + fmt.Printf("Warning: failed to write Qobuz credentials cache: %v\n", err) + } + fmt.Printf("Loaded fresh Qobuz credentials from %s (app_id=%s)\n", scrapedCreds.Source, scrapedCreds.AppID) + return qobuzCachedCredentials, nil + } + scrapeErr = fmt.Errorf("scraped qobuz credentials did not pass validation") + } + + if cachedFromDisk != nil { + qobuzCachedCredentials = cachedFromDisk + fmt.Printf("Warning: failed to refresh Qobuz credentials, using cached credentials: %v\n", scrapeErr) + return qobuzCachedCredentials, nil + } + + if qobuzCachedCredentials != nil { + fmt.Printf("Warning: failed to refresh Qobuz credentials, using in-memory credentials: %v\n", scrapeErr) + return qobuzCachedCredentials, nil + } + + fallback := defaultQobuzAPICredentials() + qobuzCachedCredentials = fallback + if scrapeErr != nil { + fmt.Printf("Warning: failed to refresh Qobuz credentials, using embedded fallback: %v\n", scrapeErr) + } + return qobuzCachedCredentials, nil +} + +func qobuzShouldRefreshCredentials(statusCode int) bool { + return statusCode == http.StatusBadRequest || statusCode == http.StatusUnauthorized +} + +func newQobuzSignedRequest(method string, path string, params url.Values) (*http.Request, error) { + creds, err := getQobuzAPICredentials(false) + if err != nil { + return nil, err + } + return newQobuzSignedRequestWithCredentials(method, path, params, creds) +} + +func doQobuzSignedRequest(method string, path string, params url.Values, client *http.Client) (*http.Response, error) { + if client == nil { + client = &http.Client{Timeout: 20 * time.Second} + } + + call := func(forceRefresh bool) (*http.Response, error) { + creds, err := getQobuzAPICredentials(forceRefresh) + if err != nil { + return nil, err + } + req, err := newQobuzSignedRequestWithCredentials(method, path, params, creds) + if err != nil { + return nil, err + } + return client.Do(req) + } + + resp, err := call(false) + if err != nil { + return nil, err + } + + if qobuzShouldRefreshCredentials(resp.StatusCode) { + resp.Body.Close() + return call(true) + } + + return resp, nil +} + +func doQobuzSignedJSONRequest(path string, params url.Values, target interface{}) error { + resp, err := doQobuzSignedRequest(http.MethodGet, path, params, &http.Client{Timeout: 20 * time.Second}) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("qobuz request failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) + } + + return json.NewDecoder(resp.Body).Decode(target) +} diff --git a/backend/recent_fetches.go b/backend/recent_fetches.go new file mode 100644 index 0000000..34d30fd --- /dev/null +++ b/backend/recent_fetches.go @@ -0,0 +1,91 @@ +package backend + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" +) + +const recentFetchesFileName = "recent_fetches.json" + +type RecentFetchItem struct { + ID string `json:"id"` + URL string `json:"url"` + Type string `json:"type"` + Name string `json:"name"` + Artist string `json:"artist"` + Image string `json:"image"` + Timestamp int64 `json:"timestamp"` +} + +var ( + recentFetchesMu sync.Mutex + recentFetchesDirResolver = GetFFmpegDir +) + +func recentFetchesFilePath() (string, error) { + baseDir, err := recentFetchesDirResolver() + if err != nil { + return "", err + } + if err := os.MkdirAll(baseDir, 0o755); err != nil { + return "", err + } + return filepath.Join(baseDir, recentFetchesFileName), nil +} + +func LoadRecentFetches() ([]RecentFetchItem, error) { + recentFetchesMu.Lock() + defer recentFetchesMu.Unlock() + + filePath, err := recentFetchesFilePath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return []RecentFetchItem{}, nil + } + return nil, err + } + + if strings.TrimSpace(string(data)) == "" { + return []RecentFetchItem{}, nil + } + + var items []RecentFetchItem + if err := json.Unmarshal(data, &items); err != nil { + return nil, err + } + + if items == nil { + return []RecentFetchItem{}, nil + } + + return items, nil +} + +func SaveRecentFetches(items []RecentFetchItem) error { + recentFetchesMu.Lock() + defer recentFetchesMu.Unlock() + + filePath, err := recentFetchesFilePath() + if err != nil { + return err + } + + if items == nil { + items = []RecentFetchItem{} + } + + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filePath, data, 0o644) +} diff --git a/backend/songlink.go b/backend/songlink.go index 919fa39..8113a21 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -47,6 +47,16 @@ type songLinkAPIResponse struct { } `json:"linksByPlatform"` } +type qobuzAvailabilityTrack struct { + ID int64 `json:"id"` + Album struct { + ID string `json:"id"` + Title string `json:"title"` + URL string `json:"url"` + RelativeURL string `json:"relative_url"` + } `json:"album"` +} + func NewSongLinkClient() *SongLinkClient { return &SongLinkClient{ client: &http.Client{ @@ -114,7 +124,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv } if isrc != "" { - availability.Qobuz = checkQobuzAvailability(isrc) + availability.Qobuz, availability.QobuzURL = checkQobuzAvailability(isrc) } if availability.Tidal || availability.Amazon || availability.Deezer || availability.Qobuz { @@ -128,36 +138,90 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv return availability, fmt.Errorf("no platforms found") } -func checkQobuzAvailability(isrc string) bool { - client := &http.Client{Timeout: 10 * time.Second} - appID := "798273057" - - searchURL := fmt.Sprintf( - "https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", - url.QueryEscape(strings.TrimSpace(isrc)), - appID, - ) - - resp, err := client.Get(searchURL) - if err != nil { - return false +func qobuzNormalizeRelativeURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" } - defer resp.Body.Close() + if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") { + return rawURL + } + if strings.HasPrefix(rawURL, "/") { + return "https://www.qobuz.com" + rawURL + } + return "https://www.qobuz.com/" + rawURL +} - if resp.StatusCode != http.StatusOK { - return false +func qobuzSlugifySegment(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" } + var builder strings.Builder + lastDash := false + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + builder.WriteRune(r) + lastDash = false + default: + if !lastDash { + builder.WriteByte('-') + lastDash = true + } + } + } + + return strings.Trim(builder.String(), "-") +} + +func qobuzAlbumSlugURL(albumTitle string, albumID string) string { + albumID = strings.TrimSpace(albumID) + if albumID == "" { + return "" + } + + slug := qobuzSlugifySegment(albumTitle) + if slug == "" { + return fmt.Sprintf("https://www.qobuz.com/album/%s", albumID) + } + + return fmt.Sprintf("https://www.qobuz.com/album/%s/%s", slug, albumID) +} + +func checkQobuzAvailability(isrc string) (bool, string) { var searchResp struct { Tracks struct { - Total int `json:"total"` + Total int `json:"total"` + Items []qobuzAvailabilityTrack `json:"items"` } `json:"tracks"` } - if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { - return false + + if err := doQobuzSignedJSONRequest("track/search", url.Values{ + "query": {strings.TrimSpace(isrc)}, + "limit": {"1"}, + }, &searchResp); err != nil { + return false, "" } - return searchResp.Tracks.Total > 0 + if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 { + return false, "" + } + + item := searchResp.Tracks.Items[0] + qobuzURL := strings.TrimSpace(item.Album.URL) + if qobuzURL == "" { + qobuzURL = qobuzNormalizeRelativeURL(item.Album.RelativeURL) + } + if qobuzURL == "" { + qobuzURL = qobuzAlbumSlugURL(item.Album.Title, item.Album.ID) + } + if qobuzURL == "" && item.ID > 0 { + qobuzURL = fmt.Sprintf("https://www.qobuz.com/us-en/track/%d", item.ID) + } + + return true, qobuzURL } func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) { diff --git a/backend/spotfetch.go b/backend/spotfetch.go index 4ca171c..f168005 100644 --- a/backend/spotfetch.go +++ b/backend/spotfetch.go @@ -15,9 +15,6 @@ import ( "time" "sort" - - "github.com/pquerna/otp" - "github.com/pquerna/otp/totp" ) var SpotifyError = errors.New("spotify error") @@ -40,21 +37,7 @@ func NewSpotifyClient() *SpotifyClient { } func (c *SpotifyClient) generateTOTP() (string, int, error) { - - secret := "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY" - version := 61 - - key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", secret)) - if err != nil { - return "", 0, err - } - - totpCode, err := totp.GenerateCode(key.Secret(), time.Now()) - if err != nil { - return "", 0, err - } - - return totpCode, version, nil + return generateSpotifyTOTP(time.Now()) } func (c *SpotifyClient) getAccessToken() error { diff --git a/backend/spotfetch_api.go b/backend/spotfetch_api.go deleted file mode 100644 index db484c0..0000000 --- a/backend/spotfetch_api.go +++ /dev/null @@ -1,185 +0,0 @@ -package backend - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "regexp" - "strings" - "time" -) - -func streamTrackListChunks(ctx context.Context, tracks []AlbumTrackMetadata, callback MetadataCallback) error { - if callback == nil || len(tracks) == 0 { - return nil - } - - const chunkSize = 25 - for start := 0; start < len(tracks); start += chunkSize { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - end := start + chunkSize - if end > len(tracks) { - end = len(tracks) - } - - callback(tracks[start:end]) - - if end < len(tracks) { - time.Sleep(15 * time.Millisecond) - } - } - - return nil -} - -func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) { - if !useAPI || apiBaseURL == "" { - return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback) - } - - spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL) - if spotifyType == "" || id == "" { - return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL) - } - - if spotifyType == "artist" { - return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback) - } - - apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id) - - req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create API request: %w", err) - } - - client := &http.Client{ - Timeout: 30 * time.Second, - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("SpotFetch API request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode) - } - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read API response: %w", err) - } - - var data interface{} - - switch spotifyType { - case "track": - var trackResp TrackResponse - if err := json.Unmarshal(bodyBytes, &trackResp); err != nil { - return nil, fmt.Errorf("failed to decode track response: %w", err) - } - data = trackResp - case "album": - var albumResp AlbumResponsePayload - if err := json.Unmarshal(bodyBytes, &albumResp); err != nil { - return nil, fmt.Errorf("failed to decode album response: %w", err) - } - data = &albumResp - if callback != nil { - callback(&AlbumResponsePayload{ - AlbumInfo: albumResp.AlbumInfo, - TrackList: []AlbumTrackMetadata{}, - }) - if err := streamTrackListChunks(ctx, albumResp.TrackList, callback); err != nil { - return nil, err - } - } - case "playlist": - var playlistResp PlaylistResponsePayload - if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil { - return nil, fmt.Errorf("failed to decode playlist response: %w", err) - } - data = playlistResp - if callback != nil { - callback(PlaylistResponsePayload{ - PlaylistInfo: playlistResp.PlaylistInfo, - TrackList: []AlbumTrackMetadata{}, - }) - if err := streamTrackListChunks(ctx, playlistResp.TrackList, callback); err != nil { - return nil, err - } - } - case "artist": - var artistResp ArtistDiscographyPayload - if err := json.Unmarshal(bodyBytes, &artistResp); err != nil { - return nil, fmt.Errorf("failed to decode artist response: %w", err) - } - data = &artistResp - if callback != nil { - callback(&ArtistDiscographyPayload{ - ArtistInfo: artistResp.ArtistInfo, - AlbumList: artistResp.AlbumList, - TrackList: []AlbumTrackMetadata{}, - }) - if err := streamTrackListChunks(ctx, artistResp.TrackList, callback); err != nil { - return nil, err - } - } - default: - return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType) - } - - if callback != nil { - switch payload := data.(type) { - case TrackResponse: - t := payload.Track - callback([]AlbumTrackMetadata{{ - SpotifyID: t.SpotifyID, - Artists: t.Artists, - Name: t.Name, - AlbumName: t.AlbumName, - AlbumArtist: t.AlbumArtist, - DurationMS: t.DurationMS, - Images: t.Images, - ReleaseDate: t.ReleaseDate, - TrackNumber: t.TrackNumber, - TotalTracks: t.TotalTracks, - DiscNumber: t.DiscNumber, - TotalDiscs: t.TotalDiscs, - ExternalURL: t.ExternalURL, - Plays: t.Plays, - PreviewURL: t.PreviewURL, - IsExplicit: t.IsExplicit, - }}) - } - } - - return data, nil -} - -func parseSpotifyURLToTypeAndID(url string) (string, string) { - - if strings.HasPrefix(url, "spotify:") { - parts := strings.Split(url, ":") - if len(parts) >= 3 { - return parts[1], parts[2] - } - } - - re := regexp.MustCompile(`spotify\.com/(track|album|playlist|artist)/([a-zA-Z0-9]+)`) - matches := re.FindStringSubmatch(url) - if len(matches) == 3 { - return matches[1], matches[2] - } - - return "", "" -} diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index 3d28633..35129ef 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -33,24 +33,31 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient { } type TrackMetadata struct { - SpotifyID string `json:"spotify_id,omitempty"` - Artists string `json:"artists"` - Name string `json:"name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist,omitempty"` - DurationMS int `json:"duration_ms"` - Images string `json:"images"` - ReleaseDate string `json:"release_date"` - TrackNumber int `json:"track_number"` - TotalTracks int `json:"total_tracks,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - TotalDiscs int `json:"total_discs,omitempty"` - ExternalURL string `json:"external_urls"` - Copyright string `json:"copyright,omitempty"` - Publisher string `json:"publisher,omitempty"` - Plays string `json:"plays,omitempty"` - PreviewURL string `json:"preview_url,omitempty"` - IsExplicit bool `json:"is_explicit,omitempty"` + SpotifyID string `json:"spotify_id,omitempty"` + Artists string `json:"artists"` + Name string `json:"name"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist,omitempty"` + DurationMS int `json:"duration_ms"` + Images string `json:"images"` + ReleaseDate string `json:"release_date"` + TrackNumber int `json:"track_number"` + TotalTracks int `json:"total_tracks,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` + ExternalURL string `json:"external_urls"` + AlbumID string `json:"album_id,omitempty"` + AlbumURL string `json:"album_url,omitempty"` + ArtistID string `json:"artist_id,omitempty"` + ArtistURL string `json:"artist_url,omitempty"` + ArtistsData []ArtistSimple `json:"artists_data,omitempty"` + UPC string `json:"upc,omitempty"` + Copyright string `json:"copyright,omitempty"` + Publisher string `json:"publisher,omitempty"` + Composer string `json:"composer,omitempty"` + Plays string `json:"plays,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` + IsExplicit bool `json:"is_explicit,omitempty"` } type ArtistSimple struct { @@ -79,6 +86,7 @@ type AlbumTrackMetadata struct { ArtistID string `json:"artist_id,omitempty"` ArtistURL string `json:"artist_url,omitempty"` ArtistsData []ArtistSimple `json:"artists_data,omitempty"` + UPC string `json:"upc,omitempty"` Plays string `json:"plays,omitempty"` Status string `json:"status,omitempty"` PreviewURL string `json:"preview_url,omitempty"` @@ -95,6 +103,7 @@ type AlbumInfoMetadata struct { ReleaseDate string `json:"release_date"` Artists string `json:"artists"` Images string `json:"images"` + UPC string `json:"upc,omitempty"` Batch string `json:"batch,omitempty"` ArtistID string `json:"artist_id,omitempty"` ArtistURL string `json:"artist_url,omitempty"` @@ -179,15 +188,18 @@ type spotifyURI struct { } type apiTrackResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - Duration string `json:"duration"` - Track int `json:"track"` - Disc int `json:"disc"` - Discs int `json:"discs"` - Copyright string `json:"copyright"` - Plays string `json:"plays"` + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + ArtistIds []string `json:"artistIds,omitempty"` + UPC string `json:"upc,omitempty"` + Duration string `json:"duration"` + Track int `json:"track"` + Disc int `json:"disc"` + Discs int `json:"discs"` + Copyright string `json:"copyright"` + Composer string `json:"composer,omitempty"` + Plays string `json:"plays"` Album struct { ID string `json:"id"` Name string `json:"name"` @@ -211,6 +223,7 @@ type apiAlbumResponse struct { Artists string `json:"artists"` Cover string `json:"cover"` ReleaseDate string `json:"releaseDate"` + UPC string `json:"upc,omitempty"` Count int `json:"count"` Label string `json:"label"` Discs struct { @@ -223,6 +236,7 @@ type apiAlbumResponse struct { ArtistIds []string `json:"artistIds"` Duration string `json:"duration"` Plays string `json:"plays"` + UPC string `json:"upc,omitempty"` IsExplicit bool `json:"is_explicit"` DiscNumber int `json:"disc_number"` } `json:"tracks"` @@ -250,6 +264,7 @@ type apiPlaylistResponse struct { Album string `json:"album"` AlbumArtist string `json:"albumArtist"` AlbumID string `json:"albumId"` + UPC string `json:"upc,omitempty"` Duration string `json:"duration"` IsExplicit bool `json:"is_explicit"` DiscNumber int `json:"disc_number"` @@ -490,6 +505,10 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) } filteredData := FilterTrack(data, c.Separator, albumFetchData) + composer, composerErr := c.fetchTrackComposerWithClient(ctx, client, trackID) + if composerErr == nil && composer != "" { + filteredData["composer"] = composer + } jsonData, err := json.Marshal(filteredData) if err != nil { @@ -501,9 +520,100 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) return nil, fmt.Errorf("failed to unmarshal to apiTrackResponse: %w", err) } + if result.ID != "" { + if identifiers, err := GetSpotifyTrackIdentifiersDirect(result.ID); err == nil || identifiers.UPC != "" { + if identifiers.UPC != "" { + result.UPC = identifiers.UPC + } + } + } + return &result, nil } +func collectTrackCreditNamesByRole(items []interface{}, role string) []string { + role = strings.TrimSpace(role) + if role == "" || len(items) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(items)) + names := make([]string, 0, len(items)) + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + if !strings.EqualFold(strings.TrimSpace(getString(itemMap, "role")), role) { + continue + } + + name := strings.TrimSpace(getString(itemMap, "name")) + if name == "" { + continue + } + if _, exists := seen[name]; exists { + continue + } + + seen[name] = struct{}{} + names = append(names, name) + } + + return names +} + +func (c *SpotifyMetadataClient) fetchTrackComposerWithClient(ctx context.Context, client *SpotifyClient, trackID string) (string, error) { + _ = ctx + + payload := map[string]interface{}{ + "variables": map[string]interface{}{ + "trackUri": fmt.Sprintf("spotify:track:%s", trackID), + "contributorsLimit": 100, + "contributorsOffset": 0, + }, + "operationName": "queryTrackCreditsModal", + "extensions": map[string]interface{}{ + "persistedQuery": map[string]interface{}{ + "version": 1, + "sha256Hash": "e2ca40d46cf1fde36562261ccec754f23fb31b561877252e9fe0d6834aabb84b", + }, + }, + } + + data, err := client.Query(payload) + if err != nil { + return "", fmt.Errorf("failed to query track credits: %w", err) + } + + creditItems := getSlice( + getMap( + getMap( + getMap( + getMap(data, "data"), + "trackUnion", + ), + "creditsTrait", + ), + "contributors", + ), + "items", + ) + + composerNames := collectTrackCreditNamesByRole(creditItems, "Composer") + if len(composerNames) == 0 { + return "", nil + } + + separator := strings.TrimSpace(c.Separator) + if separator == "" { + separator = ", " + } + + return strings.Join(composerNames, separator), nil +} + func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) { client := NewSpotifyClient() if err := client.Initialize(); err != nil { @@ -607,6 +717,17 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client return nil, fmt.Errorf("failed to unmarshal to apiAlbumResponse: %w", err) } + if result.ID != "" { + if upc, err := lookupSpotifyAlbumUPC(result.ID); err == nil && strings.TrimSpace(upc) != "" { + result.UPC = upc + for i := range result.Tracks { + if strings.TrimSpace(result.Tracks[i].UPC) == "" { + result.Tracks[i].UPC = upc + } + } + } + } + return &result, nil } @@ -895,6 +1016,34 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp durationMS := parseDuration(raw.Duration) externalURL := fmt.Sprintf("https://open.spotify.com/track/%s", raw.ID) + albumID := strings.TrimSpace(raw.Album.ID) + albumURL := "" + if albumID != "" { + albumURL = fmt.Sprintf("https://open.spotify.com/album/%s", albumID) + } + artistID := "" + artistURL := "" + artistsData := make([]ArtistSimple, 0, len(raw.ArtistIds)) + for index, id := range raw.ArtistIds { + trimmedID := strings.TrimSpace(id) + if trimmedID == "" { + continue + } + if artistID == "" { + artistID = trimmedID + artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", trimmedID) + } + artistName := "" + artistNames := splitAndCleanArtists(raw.Artists) + if index < len(artistNames) { + artistName = artistNames[index] + } + artistsData = append(artistsData, ArtistSimple{ + ID: trimmedID, + Name: artistName, + ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", trimmedID), + }) + } coverURL := raw.Cover.Small if coverURL == "" { @@ -922,8 +1071,15 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp DiscNumber: raw.Disc, TotalDiscs: raw.Discs, ExternalURL: externalURL, + AlbumID: albumID, + AlbumURL: albumURL, + ArtistID: artistID, + ArtistURL: artistURL, + ArtistsData: artistsData, + UPC: raw.UPC, Copyright: raw.Copyright, Publisher: raw.Album.Label, + Composer: raw.Composer, Plays: raw.Plays, IsExplicit: raw.IsExplicit, } @@ -935,6 +1091,18 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback MetadataCallback) (*AlbumResponsePayload, error) { var artistID, artistURL string + for _, item := range raw.Tracks { + if len(item.ArtistIds) == 0 { + continue + } + candidate := strings.TrimSpace(item.ArtistIds[0]) + if candidate == "" { + continue + } + artistID = candidate + artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", candidate) + break + } info := AlbumInfoMetadata{ TotalTracks: raw.Count, @@ -942,6 +1110,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback ReleaseDate: raw.ReleaseDate, Artists: raw.Artists, Images: raw.Cover, + UPC: raw.UPC, ArtistID: artistID, ArtistURL: artistURL, } @@ -957,6 +1126,10 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback for idx, item := range raw.Tracks { durationMS := parseDuration(item.Duration) trackNumber := idx + 1 + trackUPC := strings.TrimSpace(item.UPC) + if trackUPC == "" { + trackUPC = strings.TrimSpace(raw.UPC) + } var artistID, artistURL string if len(item.ArtistIds) > 0 { @@ -992,6 +1165,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback ArtistID: artistID, ArtistURL: artistURL, ArtistsData: artistsData, + UPC: trackUPC, Plays: item.Plays, IsExplicit: item.IsExplicit, }) @@ -1062,6 +1236,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse, cal ArtistID: artistID, ArtistURL: artistURL, ArtistsData: artistsData, + UPC: item.UPC, Plays: item.Plays, Status: item.Status, IsExplicit: item.IsExplicit, @@ -1188,6 +1363,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, TrackNumber: trackNumber, TotalTracks: albumData.Count, DiscNumber: tr.DiscNumber, + UPC: tr.UPC, ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID), AlbumID: albumID, AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", albumID), @@ -1321,6 +1497,18 @@ func parseArtistIDsFromString(artists string) []string { return []string{} } +func splitAndCleanArtists(artists string) []string { + raw := regexp.MustCompile(`\s*[;,]\s*`).Split(strings.TrimSpace(artists), -1) + parts := make([]string, 0, len(raw)) + for _, part := range raw { + part = strings.TrimSpace(part) + if part != "" { + parts = append(parts, part) + } + } + return parts +} + func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit int) (*SearchResponse, error) { if query == "" { return nil, errors.New("search query cannot be empty") diff --git a/backend/spotify_totp.go b/backend/spotify_totp.go new file mode 100644 index 0000000..3f5faa5 --- /dev/null +++ b/backend/spotify_totp.go @@ -0,0 +1,28 @@ +package backend + +import ( + "fmt" + "time" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +const ( + spotifyTOTPSecret = "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY" + spotifyTOTPVersion = 61 +) + +func generateSpotifyTOTP(now time.Time) (string, int, error) { + key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", spotifyTOTPSecret)) + if err != nil { + return "", 0, err + } + + code, err := totp.GenerateCode(key.Secret(), now) + if err != nil { + return "", 0, err + } + + return code, spotifyTOTPVersion, nil +} diff --git a/backend/tidal.go b/backend/tidal.go index 402c81e..ee16973 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -416,7 +416,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e return nil } -func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) @@ -449,11 +449,12 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo trackTitleForFile := sanitizeFilename(trackTitle) albumTitleForFile := sanitizeFilename(albumTitle) - filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride) outputFilename := filepath.Join(outputDir, filename) - if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { - fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) + outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting()) + if alreadyExists { + fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024)) return "EXISTS:" + outputFilename, nil } @@ -492,12 +493,16 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } res.ISRC = isrc if isrc != "" { - fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { - res.Metadata = fetchedMeta - fmt.Println("✓ MusicBrainz metadata fetched") + 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, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { + res.Metadata = fetchedMeta + fmt.Println("✓ MusicBrainz metadata fetched") + } else { + fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + } } } metaChan <- res @@ -511,11 +516,13 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo 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 } @@ -554,7 +561,9 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo 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, } @@ -570,7 +579,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo return outputFilename, nil } -func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) @@ -608,11 +617,12 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality trackTitleForFile := sanitizeFilename(trackTitle) albumTitleForFile := sanitizeFilename(albumTitle) - filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride) outputFilename := filepath.Join(outputDir, filename) - if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { - fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) + outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting()) + if alreadyExists { + fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024)) return "EXISTS:" + outputFilename, nil } @@ -651,12 +661,16 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality } res.ISRC = isrc if isrc != "" { - fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { - res.Metadata = fetchedMeta - fmt.Println("✓ MusicBrainz metadata fetched") + 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, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { + res.Metadata = fetchedMeta + fmt.Println("✓ MusicBrainz metadata fetched") + } else { + fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + } } } metaChan <- res @@ -671,11 +685,13 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality 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 } @@ -714,7 +730,9 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality 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, } @@ -730,14 +748,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return outputFilename, nil } -func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err) } - return t.DownloadByURLWithFallback(tidalURL, 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 t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) } type SegmentTemplate struct { @@ -977,8 +995,12 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string return "", "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError) } -func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { +func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, extra ...string) string { var filename string + isrc := "" + if len(extra) > 0 { + isrc = SanitizeOptionalFilename(extra[0]) + } numberToUse := position if useAlbumTrackNumber && trackNumber > 0 { @@ -998,6 +1020,7 @@ func buildTidalFilename(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)) diff --git a/backend/upc_tags.go b/backend/upc_tags.go new file mode 100644 index 0000000..14a638f --- /dev/null +++ b/backend/upc_tags.go @@ -0,0 +1,50 @@ +package backend + +import "strings" + +const preferredUPCTagKey = "UPC" + +var ffprobeUPCTagKeys = []string{ + "upc", + "barcode", + "wm/upc", + "txxx:upc", + "txxx:barcode", + "txxx/upc", + "txxx/barcode", + "----:com.apple.itunes:upc", + "----:com.apple.itunes:barcode", +} + +func assignPreferredUPC(current *string, incoming string, preferred bool) { + incoming = strings.TrimSpace(incoming) + if incoming == "" { + return + } + + if preferred || strings.TrimSpace(*current) == "" { + *current = incoming + } +} + +func classifyUPCDescription(description string) (matched bool, preferred bool) { + switch strings.ToUpper(strings.TrimSpace(description)) { + case preferredUPCTagKey: + return true, true + case "BARCODE": + return true, false + default: + return false, false + } +} + +func firstPreferredFFprobeUPCValue(tags map[string]string) string { + for _, key := range ffprobeUPCTagKeys { + value := strings.TrimSpace(tags[key]) + if value != "" { + return value + } + } + + return "" +} diff --git a/frontend/public/assets/flags/ad.svg b/frontend/public/assets/flags/ad.svg new file mode 100644 index 0000000..199ff19 --- /dev/null +++ b/frontend/public/assets/flags/ad.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ae.svg b/frontend/public/assets/flags/ae.svg new file mode 100644 index 0000000..651ac85 --- /dev/null +++ b/frontend/public/assets/flags/ae.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/af.svg b/frontend/public/assets/flags/af.svg new file mode 100644 index 0000000..4dbe455 --- /dev/null +++ b/frontend/public/assets/flags/af.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ag.svg b/frontend/public/assets/flags/ag.svg new file mode 100644 index 0000000..243c3d8 --- /dev/null +++ b/frontend/public/assets/flags/ag.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ai.svg b/frontend/public/assets/flags/ai.svg new file mode 100644 index 0000000..9c2ea33 --- /dev/null +++ b/frontend/public/assets/flags/ai.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/al.svg b/frontend/public/assets/flags/al.svg new file mode 100644 index 0000000..e85d95f --- /dev/null +++ b/frontend/public/assets/flags/al.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/am.svg b/frontend/public/assets/flags/am.svg new file mode 100644 index 0000000..99fa4dc --- /dev/null +++ b/frontend/public/assets/flags/am.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/ao.svg b/frontend/public/assets/flags/ao.svg new file mode 100644 index 0000000..b73b1ec --- /dev/null +++ b/frontend/public/assets/flags/ao.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/aq.svg b/frontend/public/assets/flags/aq.svg new file mode 100644 index 0000000..c7e3536 --- /dev/null +++ b/frontend/public/assets/flags/aq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/ar.svg b/frontend/public/assets/flags/ar.svg new file mode 100644 index 0000000..c753da1 --- /dev/null +++ b/frontend/public/assets/flags/ar.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/arab.svg b/frontend/public/assets/flags/arab.svg new file mode 100644 index 0000000..9ef079f --- /dev/null +++ b/frontend/public/assets/flags/arab.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/as.svg b/frontend/public/assets/flags/as.svg new file mode 100644 index 0000000..82459de --- /dev/null +++ b/frontend/public/assets/flags/as.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/asean.svg b/frontend/public/assets/flags/asean.svg new file mode 100644 index 0000000..189ae02 --- /dev/null +++ b/frontend/public/assets/flags/asean.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/at.svg b/frontend/public/assets/flags/at.svg new file mode 100644 index 0000000..9d2775c --- /dev/null +++ b/frontend/public/assets/flags/at.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/au.svg b/frontend/public/assets/flags/au.svg new file mode 100644 index 0000000..96e8076 --- /dev/null +++ b/frontend/public/assets/flags/au.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/aw.svg b/frontend/public/assets/flags/aw.svg new file mode 100644 index 0000000..413b7c4 --- /dev/null +++ b/frontend/public/assets/flags/aw.svg @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ax.svg b/frontend/public/assets/flags/ax.svg new file mode 100644 index 0000000..0584d71 --- /dev/null +++ b/frontend/public/assets/flags/ax.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/az.svg b/frontend/public/assets/flags/az.svg new file mode 100644 index 0000000..3557522 --- /dev/null +++ b/frontend/public/assets/flags/az.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/ba.svg b/frontend/public/assets/flags/ba.svg new file mode 100644 index 0000000..93bd9cf --- /dev/null +++ b/frontend/public/assets/flags/ba.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bb.svg b/frontend/public/assets/flags/bb.svg new file mode 100644 index 0000000..cecd5cc --- /dev/null +++ b/frontend/public/assets/flags/bb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/bd.svg b/frontend/public/assets/flags/bd.svg new file mode 100644 index 0000000..16b794d --- /dev/null +++ b/frontend/public/assets/flags/bd.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/be.svg b/frontend/public/assets/flags/be.svg new file mode 100644 index 0000000..ac706a0 --- /dev/null +++ b/frontend/public/assets/flags/be.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/bf.svg b/frontend/public/assets/flags/bf.svg new file mode 100644 index 0000000..4713822 --- /dev/null +++ b/frontend/public/assets/flags/bf.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/bg.svg b/frontend/public/assets/flags/bg.svg new file mode 100644 index 0000000..af2d0d0 --- /dev/null +++ b/frontend/public/assets/flags/bg.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/bh.svg b/frontend/public/assets/flags/bh.svg new file mode 100644 index 0000000..7a2ea54 --- /dev/null +++ b/frontend/public/assets/flags/bh.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/bi.svg b/frontend/public/assets/flags/bi.svg new file mode 100644 index 0000000..a4434a9 --- /dev/null +++ b/frontend/public/assets/flags/bi.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bj.svg b/frontend/public/assets/flags/bj.svg new file mode 100644 index 0000000..0846724 --- /dev/null +++ b/frontend/public/assets/flags/bj.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bl.svg b/frontend/public/assets/flags/bl.svg new file mode 100644 index 0000000..f84cbba --- /dev/null +++ b/frontend/public/assets/flags/bl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/bm.svg b/frontend/public/assets/flags/bm.svg new file mode 100644 index 0000000..f43a5eb --- /dev/null +++ b/frontend/public/assets/flags/bm.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bn.svg b/frontend/public/assets/flags/bn.svg new file mode 100644 index 0000000..f544c25 --- /dev/null +++ b/frontend/public/assets/flags/bn.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bo.svg b/frontend/public/assets/flags/bo.svg new file mode 100644 index 0000000..7658e3f --- /dev/null +++ b/frontend/public/assets/flags/bo.svg @@ -0,0 +1,673 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bq.svg b/frontend/public/assets/flags/bq.svg new file mode 100644 index 0000000..0e6bc76 --- /dev/null +++ b/frontend/public/assets/flags/bq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/br.svg b/frontend/public/assets/flags/br.svg new file mode 100644 index 0000000..719a763 --- /dev/null +++ b/frontend/public/assets/flags/br.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bs.svg b/frontend/public/assets/flags/bs.svg new file mode 100644 index 0000000..5cc918e --- /dev/null +++ b/frontend/public/assets/flags/bs.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bt.svg b/frontend/public/assets/flags/bt.svg new file mode 100644 index 0000000..20aef3a --- /dev/null +++ b/frontend/public/assets/flags/bt.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bv.svg b/frontend/public/assets/flags/bv.svg new file mode 100644 index 0000000..40e16d9 --- /dev/null +++ b/frontend/public/assets/flags/bv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bw.svg b/frontend/public/assets/flags/bw.svg new file mode 100644 index 0000000..3435608 --- /dev/null +++ b/frontend/public/assets/flags/bw.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/by.svg b/frontend/public/assets/flags/by.svg new file mode 100644 index 0000000..948784f --- /dev/null +++ b/frontend/public/assets/flags/by.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bz.svg b/frontend/public/assets/flags/bz.svg new file mode 100644 index 0000000..d81b16c --- /dev/null +++ b/frontend/public/assets/flags/bz.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ca.svg b/frontend/public/assets/flags/ca.svg new file mode 100644 index 0000000..c9b23b4 --- /dev/null +++ b/frontend/public/assets/flags/ca.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/cc.svg b/frontend/public/assets/flags/cc.svg new file mode 100644 index 0000000..a42dec6 --- /dev/null +++ b/frontend/public/assets/flags/cc.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cd.svg b/frontend/public/assets/flags/cd.svg new file mode 100644 index 0000000..b9cf528 --- /dev/null +++ b/frontend/public/assets/flags/cd.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/cefta.svg b/frontend/public/assets/flags/cefta.svg new file mode 100644 index 0000000..f748d08 --- /dev/null +++ b/frontend/public/assets/flags/cefta.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cf.svg b/frontend/public/assets/flags/cf.svg new file mode 100644 index 0000000..a6cd367 --- /dev/null +++ b/frontend/public/assets/flags/cf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cg.svg b/frontend/public/assets/flags/cg.svg new file mode 100644 index 0000000..f5a0e42 --- /dev/null +++ b/frontend/public/assets/flags/cg.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ch.svg b/frontend/public/assets/flags/ch.svg new file mode 100644 index 0000000..b42d670 --- /dev/null +++ b/frontend/public/assets/flags/ch.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/ci.svg b/frontend/public/assets/flags/ci.svg new file mode 100644 index 0000000..e400f0c --- /dev/null +++ b/frontend/public/assets/flags/ci.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/ck.svg b/frontend/public/assets/flags/ck.svg new file mode 100644 index 0000000..18e547b --- /dev/null +++ b/frontend/public/assets/flags/ck.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/cl.svg b/frontend/public/assets/flags/cl.svg new file mode 100644 index 0000000..5b3c72f --- /dev/null +++ b/frontend/public/assets/flags/cl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cm.svg b/frontend/public/assets/flags/cm.svg new file mode 100644 index 0000000..70adc8b --- /dev/null +++ b/frontend/public/assets/flags/cm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cn.svg b/frontend/public/assets/flags/cn.svg new file mode 100644 index 0000000..10d3489 --- /dev/null +++ b/frontend/public/assets/flags/cn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/assets/flags/co.svg b/frontend/public/assets/flags/co.svg new file mode 100644 index 0000000..ebd0a0f --- /dev/null +++ b/frontend/public/assets/flags/co.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/cp.svg b/frontend/public/assets/flags/cp.svg new file mode 100644 index 0000000..b8aa9cf --- /dev/null +++ b/frontend/public/assets/flags/cp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/cr.svg b/frontend/public/assets/flags/cr.svg new file mode 100644 index 0000000..5a409ee --- /dev/null +++ b/frontend/public/assets/flags/cr.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/cu.svg b/frontend/public/assets/flags/cu.svg new file mode 100644 index 0000000..053c9ee --- /dev/null +++ b/frontend/public/assets/flags/cu.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cv.svg b/frontend/public/assets/flags/cv.svg new file mode 100644 index 0000000..aec8994 --- /dev/null +++ b/frontend/public/assets/flags/cv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cw.svg b/frontend/public/assets/flags/cw.svg new file mode 100644 index 0000000..bb0ece2 --- /dev/null +++ b/frontend/public/assets/flags/cw.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cx.svg b/frontend/public/assets/flags/cx.svg new file mode 100644 index 0000000..3a83c23 --- /dev/null +++ b/frontend/public/assets/flags/cx.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cy.svg b/frontend/public/assets/flags/cy.svg new file mode 100644 index 0000000..ee4b0c7 --- /dev/null +++ b/frontend/public/assets/flags/cy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/cz.svg b/frontend/public/assets/flags/cz.svg new file mode 100644 index 0000000..7913de3 --- /dev/null +++ b/frontend/public/assets/flags/cz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/de.svg b/frontend/public/assets/flags/de.svg new file mode 100644 index 0000000..71aa2d2 --- /dev/null +++ b/frontend/public/assets/flags/de.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/dg.svg b/frontend/public/assets/flags/dg.svg new file mode 100644 index 0000000..dfee2bb --- /dev/null +++ b/frontend/public/assets/flags/dg.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/dj.svg b/frontend/public/assets/flags/dj.svg new file mode 100644 index 0000000..9b00a82 --- /dev/null +++ b/frontend/public/assets/flags/dj.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/dk.svg b/frontend/public/assets/flags/dk.svg new file mode 100644 index 0000000..563277f --- /dev/null +++ b/frontend/public/assets/flags/dk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/dm.svg b/frontend/public/assets/flags/dm.svg new file mode 100644 index 0000000..5aa9cea --- /dev/null +++ b/frontend/public/assets/flags/dm.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/do.svg b/frontend/public/assets/flags/do.svg new file mode 100644 index 0000000..6de2b26 --- /dev/null +++ b/frontend/public/assets/flags/do.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/dz.svg b/frontend/public/assets/flags/dz.svg new file mode 100644 index 0000000..5ff29a7 --- /dev/null +++ b/frontend/public/assets/flags/dz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/eac.svg b/frontend/public/assets/flags/eac.svg new file mode 100644 index 0000000..59d02d2 --- /dev/null +++ b/frontend/public/assets/flags/eac.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ec.svg b/frontend/public/assets/flags/ec.svg new file mode 100644 index 0000000..88c50bf --- /dev/null +++ b/frontend/public/assets/flags/ec.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ee.svg b/frontend/public/assets/flags/ee.svg new file mode 100644 index 0000000..8b98c2c --- /dev/null +++ b/frontend/public/assets/flags/ee.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/eg.svg b/frontend/public/assets/flags/eg.svg new file mode 100644 index 0000000..88e32b3 --- /dev/null +++ b/frontend/public/assets/flags/eg.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/eh.svg b/frontend/public/assets/flags/eh.svg new file mode 100644 index 0000000..6aec728 --- /dev/null +++ b/frontend/public/assets/flags/eh.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/er.svg b/frontend/public/assets/flags/er.svg new file mode 100644 index 0000000..48a13b4 --- /dev/null +++ b/frontend/public/assets/flags/er.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/es-ct.svg b/frontend/public/assets/flags/es-ct.svg new file mode 100644 index 0000000..4d85911 --- /dev/null +++ b/frontend/public/assets/flags/es-ct.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/es-ga.svg b/frontend/public/assets/flags/es-ga.svg new file mode 100644 index 0000000..573ca45 --- /dev/null +++ b/frontend/public/assets/flags/es-ga.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/es-pv.svg b/frontend/public/assets/flags/es-pv.svg new file mode 100644 index 0000000..63c19f4 --- /dev/null +++ b/frontend/public/assets/flags/es-pv.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/es.svg b/frontend/public/assets/flags/es.svg new file mode 100644 index 0000000..a296ebf --- /dev/null +++ b/frontend/public/assets/flags/es.svg @@ -0,0 +1,544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/et.svg b/frontend/public/assets/flags/et.svg new file mode 100644 index 0000000..3f99be4 --- /dev/null +++ b/frontend/public/assets/flags/et.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/eu.svg b/frontend/public/assets/flags/eu.svg new file mode 100644 index 0000000..b0874c1 --- /dev/null +++ b/frontend/public/assets/flags/eu.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/fi.svg b/frontend/public/assets/flags/fi.svg new file mode 100644 index 0000000..470be2d --- /dev/null +++ b/frontend/public/assets/flags/fi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/fj.svg b/frontend/public/assets/flags/fj.svg new file mode 100644 index 0000000..332ae61 --- /dev/null +++ b/frontend/public/assets/flags/fj.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/fk.svg b/frontend/public/assets/flags/fk.svg new file mode 100644 index 0000000..a0dace8 --- /dev/null +++ b/frontend/public/assets/flags/fk.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/fm.svg b/frontend/public/assets/flags/fm.svg new file mode 100644 index 0000000..c1b7c97 --- /dev/null +++ b/frontend/public/assets/flags/fm.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/assets/flags/fo.svg b/frontend/public/assets/flags/fo.svg new file mode 100644 index 0000000..f802d28 --- /dev/null +++ b/frontend/public/assets/flags/fo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/fr.svg b/frontend/public/assets/flags/fr.svg new file mode 100644 index 0000000..e682b90 --- /dev/null +++ b/frontend/public/assets/flags/fr.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/ga.svg b/frontend/public/assets/flags/ga.svg new file mode 100644 index 0000000..76edab4 --- /dev/null +++ b/frontend/public/assets/flags/ga.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/gb-eng.svg b/frontend/public/assets/flags/gb-eng.svg new file mode 100644 index 0000000..12e3b67 --- /dev/null +++ b/frontend/public/assets/flags/gb-eng.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/gb-nir.svg b/frontend/public/assets/flags/gb-nir.svg new file mode 100644 index 0000000..e22190a --- /dev/null +++ b/frontend/public/assets/flags/gb-nir.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gb-sct.svg b/frontend/public/assets/flags/gb-sct.svg new file mode 100644 index 0000000..f50cd32 --- /dev/null +++ b/frontend/public/assets/flags/gb-sct.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/gb-wls.svg b/frontend/public/assets/flags/gb-wls.svg new file mode 100644 index 0000000..d7f5791 --- /dev/null +++ b/frontend/public/assets/flags/gb-wls.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/gb.svg b/frontend/public/assets/flags/gb.svg new file mode 100644 index 0000000..7991383 --- /dev/null +++ b/frontend/public/assets/flags/gb.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/gd.svg b/frontend/public/assets/flags/gd.svg new file mode 100644 index 0000000..b3d250d --- /dev/null +++ b/frontend/public/assets/flags/gd.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ge.svg b/frontend/public/assets/flags/ge.svg new file mode 100644 index 0000000..ab08a9a --- /dev/null +++ b/frontend/public/assets/flags/ge.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/gf.svg b/frontend/public/assets/flags/gf.svg new file mode 100644 index 0000000..f8fe94c --- /dev/null +++ b/frontend/public/assets/flags/gf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/gg.svg b/frontend/public/assets/flags/gg.svg new file mode 100644 index 0000000..f8216c8 --- /dev/null +++ b/frontend/public/assets/flags/gg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/gh.svg b/frontend/public/assets/flags/gh.svg new file mode 100644 index 0000000..5c3e3e6 --- /dev/null +++ b/frontend/public/assets/flags/gh.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/gi.svg b/frontend/public/assets/flags/gi.svg new file mode 100644 index 0000000..a5d7570 --- /dev/null +++ b/frontend/public/assets/flags/gi.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gl.svg b/frontend/public/assets/flags/gl.svg new file mode 100644 index 0000000..eb5a52e --- /dev/null +++ b/frontend/public/assets/flags/gl.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/gm.svg b/frontend/public/assets/flags/gm.svg new file mode 100644 index 0000000..8fe9d66 --- /dev/null +++ b/frontend/public/assets/flags/gm.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gn.svg b/frontend/public/assets/flags/gn.svg new file mode 100644 index 0000000..40d6ad4 --- /dev/null +++ b/frontend/public/assets/flags/gn.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/gp.svg b/frontend/public/assets/flags/gp.svg new file mode 100644 index 0000000..ee55c4b --- /dev/null +++ b/frontend/public/assets/flags/gp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/gq.svg b/frontend/public/assets/flags/gq.svg new file mode 100644 index 0000000..64c8eb2 --- /dev/null +++ b/frontend/public/assets/flags/gq.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gr.svg b/frontend/public/assets/flags/gr.svg new file mode 100644 index 0000000..599741e --- /dev/null +++ b/frontend/public/assets/flags/gr.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gs.svg b/frontend/public/assets/flags/gs.svg new file mode 100644 index 0000000..29db9b9 --- /dev/null +++ b/frontend/public/assets/flags/gs.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gt.svg b/frontend/public/assets/flags/gt.svg new file mode 100644 index 0000000..7df9df5 --- /dev/null +++ b/frontend/public/assets/flags/gt.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gu.svg b/frontend/public/assets/flags/gu.svg new file mode 100644 index 0000000..3b95219 --- /dev/null +++ b/frontend/public/assets/flags/gu.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gw.svg b/frontend/public/assets/flags/gw.svg new file mode 100644 index 0000000..d470bac --- /dev/null +++ b/frontend/public/assets/flags/gw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gy.svg b/frontend/public/assets/flags/gy.svg new file mode 100644 index 0000000..569fb56 --- /dev/null +++ b/frontend/public/assets/flags/gy.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/hk.svg b/frontend/public/assets/flags/hk.svg new file mode 100644 index 0000000..4fd55bc --- /dev/null +++ b/frontend/public/assets/flags/hk.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/hm.svg b/frontend/public/assets/flags/hm.svg new file mode 100644 index 0000000..815c482 --- /dev/null +++ b/frontend/public/assets/flags/hm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/hn.svg b/frontend/public/assets/flags/hn.svg new file mode 100644 index 0000000..11fde67 --- /dev/null +++ b/frontend/public/assets/flags/hn.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/hr.svg b/frontend/public/assets/flags/hr.svg new file mode 100644 index 0000000..dde825c --- /dev/null +++ b/frontend/public/assets/flags/hr.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ht.svg b/frontend/public/assets/flags/ht.svg new file mode 100644 index 0000000..8e8efc4 --- /dev/null +++ b/frontend/public/assets/flags/ht.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/hu.svg b/frontend/public/assets/flags/hu.svg new file mode 100644 index 0000000..24fbfb9 --- /dev/null +++ b/frontend/public/assets/flags/hu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/ic.svg b/frontend/public/assets/flags/ic.svg new file mode 100644 index 0000000..81e6ee2 --- /dev/null +++ b/frontend/public/assets/flags/ic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/id.svg b/frontend/public/assets/flags/id.svg new file mode 100644 index 0000000..3b7c8fc --- /dev/null +++ b/frontend/public/assets/flags/id.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/ie.svg b/frontend/public/assets/flags/ie.svg new file mode 100644 index 0000000..049be14 --- /dev/null +++ b/frontend/public/assets/flags/ie.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/il.svg b/frontend/public/assets/flags/il.svg new file mode 100644 index 0000000..f43be7e --- /dev/null +++ b/frontend/public/assets/flags/il.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/im.svg b/frontend/public/assets/flags/im.svg new file mode 100644 index 0000000..fe6a59a --- /dev/null +++ b/frontend/public/assets/flags/im.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/in.svg b/frontend/public/assets/flags/in.svg new file mode 100644 index 0000000..bc47d74 --- /dev/null +++ b/frontend/public/assets/flags/in.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/io.svg b/frontend/public/assets/flags/io.svg new file mode 100644 index 0000000..3058f7d --- /dev/null +++ b/frontend/public/assets/flags/io.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/iq.svg b/frontend/public/assets/flags/iq.svg new file mode 100644 index 0000000..8044514 --- /dev/null +++ b/frontend/public/assets/flags/iq.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/assets/flags/ir.svg b/frontend/public/assets/flags/ir.svg new file mode 100644 index 0000000..8c6d516 --- /dev/null +++ b/frontend/public/assets/flags/ir.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/is.svg b/frontend/public/assets/flags/is.svg new file mode 100644 index 0000000..a6588af --- /dev/null +++ b/frontend/public/assets/flags/is.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/it.svg b/frontend/public/assets/flags/it.svg new file mode 100644 index 0000000..20a8bfd --- /dev/null +++ b/frontend/public/assets/flags/it.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/je.svg b/frontend/public/assets/flags/je.svg new file mode 100644 index 0000000..70a8754 --- /dev/null +++ b/frontend/public/assets/flags/je.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/jm.svg b/frontend/public/assets/flags/jm.svg new file mode 100644 index 0000000..269df03 --- /dev/null +++ b/frontend/public/assets/flags/jm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/jo.svg b/frontend/public/assets/flags/jo.svg new file mode 100644 index 0000000..d6f927d --- /dev/null +++ b/frontend/public/assets/flags/jo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/jp.svg b/frontend/public/assets/flags/jp.svg new file mode 100644 index 0000000..cc1c181 --- /dev/null +++ b/frontend/public/assets/flags/jp.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ke.svg b/frontend/public/assets/flags/ke.svg new file mode 100644 index 0000000..3a67ca3 --- /dev/null +++ b/frontend/public/assets/flags/ke.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/kg.svg b/frontend/public/assets/flags/kg.svg new file mode 100644 index 0000000..e26db95 --- /dev/null +++ b/frontend/public/assets/flags/kg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/kh.svg b/frontend/public/assets/flags/kh.svg new file mode 100644 index 0000000..a7d52f2 --- /dev/null +++ b/frontend/public/assets/flags/kh.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ki.svg b/frontend/public/assets/flags/ki.svg new file mode 100644 index 0000000..fda03f3 --- /dev/null +++ b/frontend/public/assets/flags/ki.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/km.svg b/frontend/public/assets/flags/km.svg new file mode 100644 index 0000000..414d65e --- /dev/null +++ b/frontend/public/assets/flags/km.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/kn.svg b/frontend/public/assets/flags/kn.svg new file mode 100644 index 0000000..47fe64d --- /dev/null +++ b/frontend/public/assets/flags/kn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/kp.svg b/frontend/public/assets/flags/kp.svg new file mode 100644 index 0000000..ad1b713 --- /dev/null +++ b/frontend/public/assets/flags/kp.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/kr.svg b/frontend/public/assets/flags/kr.svg new file mode 100644 index 0000000..6947eab --- /dev/null +++ b/frontend/public/assets/flags/kr.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/kw.svg b/frontend/public/assets/flags/kw.svg new file mode 100644 index 0000000..3dd89e9 --- /dev/null +++ b/frontend/public/assets/flags/kw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ky.svg b/frontend/public/assets/flags/ky.svg new file mode 100644 index 0000000..aeaa7e0 --- /dev/null +++ b/frontend/public/assets/flags/ky.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/kz.svg b/frontend/public/assets/flags/kz.svg new file mode 100644 index 0000000..2fac45b --- /dev/null +++ b/frontend/public/assets/flags/kz.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/la.svg b/frontend/public/assets/flags/la.svg new file mode 100644 index 0000000..6aea6b7 --- /dev/null +++ b/frontend/public/assets/flags/la.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/lb.svg b/frontend/public/assets/flags/lb.svg new file mode 100644 index 0000000..bde2581 --- /dev/null +++ b/frontend/public/assets/flags/lb.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/lc.svg b/frontend/public/assets/flags/lc.svg new file mode 100644 index 0000000..bb25654 --- /dev/null +++ b/frontend/public/assets/flags/lc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/li.svg b/frontend/public/assets/flags/li.svg new file mode 100644 index 0000000..7a4d183 --- /dev/null +++ b/frontend/public/assets/flags/li.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/lk.svg b/frontend/public/assets/flags/lk.svg new file mode 100644 index 0000000..cbd660a --- /dev/null +++ b/frontend/public/assets/flags/lk.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/lr.svg b/frontend/public/assets/flags/lr.svg new file mode 100644 index 0000000..e482ab9 --- /dev/null +++ b/frontend/public/assets/flags/lr.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ls.svg b/frontend/public/assets/flags/ls.svg new file mode 100644 index 0000000..a7c01a9 --- /dev/null +++ b/frontend/public/assets/flags/ls.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/lt.svg b/frontend/public/assets/flags/lt.svg new file mode 100644 index 0000000..90ec5d2 --- /dev/null +++ b/frontend/public/assets/flags/lt.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/lu.svg b/frontend/public/assets/flags/lu.svg new file mode 100644 index 0000000..cc12206 --- /dev/null +++ b/frontend/public/assets/flags/lu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/lv.svg b/frontend/public/assets/flags/lv.svg new file mode 100644 index 0000000..f6decec --- /dev/null +++ b/frontend/public/assets/flags/lv.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/ly.svg b/frontend/public/assets/flags/ly.svg new file mode 100644 index 0000000..1eaa51e --- /dev/null +++ b/frontend/public/assets/flags/ly.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ma.svg b/frontend/public/assets/flags/ma.svg new file mode 100644 index 0000000..7ce56ef --- /dev/null +++ b/frontend/public/assets/flags/ma.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/mc.svg b/frontend/public/assets/flags/mc.svg new file mode 100644 index 0000000..9cb6c9e --- /dev/null +++ b/frontend/public/assets/flags/mc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/md.svg b/frontend/public/assets/flags/md.svg new file mode 100644 index 0000000..e9ba506 --- /dev/null +++ b/frontend/public/assets/flags/md.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/me.svg b/frontend/public/assets/flags/me.svg new file mode 100644 index 0000000..297888c --- /dev/null +++ b/frontend/public/assets/flags/me.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mf.svg b/frontend/public/assets/flags/mf.svg new file mode 100644 index 0000000..6305edc --- /dev/null +++ b/frontend/public/assets/flags/mf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/mg.svg b/frontend/public/assets/flags/mg.svg new file mode 100644 index 0000000..5fa2d24 --- /dev/null +++ b/frontend/public/assets/flags/mg.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/mh.svg b/frontend/public/assets/flags/mh.svg new file mode 100644 index 0000000..7b9f490 --- /dev/null +++ b/frontend/public/assets/flags/mh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/mk.svg b/frontend/public/assets/flags/mk.svg new file mode 100644 index 0000000..4f5cae7 --- /dev/null +++ b/frontend/public/assets/flags/mk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/ml.svg b/frontend/public/assets/flags/ml.svg new file mode 100644 index 0000000..6f6b716 --- /dev/null +++ b/frontend/public/assets/flags/ml.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/mm.svg b/frontend/public/assets/flags/mm.svg new file mode 100644 index 0000000..42b4dee --- /dev/null +++ b/frontend/public/assets/flags/mm.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mn.svg b/frontend/public/assets/flags/mn.svg new file mode 100644 index 0000000..6a38a71 --- /dev/null +++ b/frontend/public/assets/flags/mn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mo.svg b/frontend/public/assets/flags/mo.svg new file mode 100644 index 0000000..f638b6c --- /dev/null +++ b/frontend/public/assets/flags/mo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/mp.svg b/frontend/public/assets/flags/mp.svg new file mode 100644 index 0000000..26bfa22 --- /dev/null +++ b/frontend/public/assets/flags/mp.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mq.svg b/frontend/public/assets/flags/mq.svg new file mode 100644 index 0000000..b221951 --- /dev/null +++ b/frontend/public/assets/flags/mq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/mr.svg b/frontend/public/assets/flags/mr.svg new file mode 100644 index 0000000..d859972 --- /dev/null +++ b/frontend/public/assets/flags/mr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/ms.svg b/frontend/public/assets/flags/ms.svg new file mode 100644 index 0000000..4367505 --- /dev/null +++ b/frontend/public/assets/flags/ms.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mt.svg b/frontend/public/assets/flags/mt.svg new file mode 100644 index 0000000..5d5d7c8 --- /dev/null +++ b/frontend/public/assets/flags/mt.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mu.svg b/frontend/public/assets/flags/mu.svg new file mode 100644 index 0000000..82d7a3b --- /dev/null +++ b/frontend/public/assets/flags/mu.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/mv.svg b/frontend/public/assets/flags/mv.svg new file mode 100644 index 0000000..10450f9 --- /dev/null +++ b/frontend/public/assets/flags/mv.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/mw.svg b/frontend/public/assets/flags/mw.svg new file mode 100644 index 0000000..137ff87 --- /dev/null +++ b/frontend/public/assets/flags/mw.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/assets/flags/mx.svg b/frontend/public/assets/flags/mx.svg new file mode 100644 index 0000000..e3ec2bc --- /dev/null +++ b/frontend/public/assets/flags/mx.svg @@ -0,0 +1,382 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/my.svg b/frontend/public/assets/flags/my.svg new file mode 100644 index 0000000..115f864 --- /dev/null +++ b/frontend/public/assets/flags/my.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mz.svg b/frontend/public/assets/flags/mz.svg new file mode 100644 index 0000000..0f94c3a --- /dev/null +++ b/frontend/public/assets/flags/mz.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/na.svg b/frontend/public/assets/flags/na.svg new file mode 100644 index 0000000..35b9f78 --- /dev/null +++ b/frontend/public/assets/flags/na.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/nc.svg b/frontend/public/assets/flags/nc.svg new file mode 100644 index 0000000..fa15551 --- /dev/null +++ b/frontend/public/assets/flags/nc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ne.svg b/frontend/public/assets/flags/ne.svg new file mode 100644 index 0000000..39a82b8 --- /dev/null +++ b/frontend/public/assets/flags/ne.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/nf.svg b/frontend/public/assets/flags/nf.svg new file mode 100644 index 0000000..fd61b25 --- /dev/null +++ b/frontend/public/assets/flags/nf.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/ng.svg b/frontend/public/assets/flags/ng.svg new file mode 100644 index 0000000..81eb35f --- /dev/null +++ b/frontend/public/assets/flags/ng.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/ni.svg b/frontend/public/assets/flags/ni.svg new file mode 100644 index 0000000..e4861f5 --- /dev/null +++ b/frontend/public/assets/flags/ni.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/nl.svg b/frontend/public/assets/flags/nl.svg new file mode 100644 index 0000000..e90f5b0 --- /dev/null +++ b/frontend/public/assets/flags/nl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/no.svg b/frontend/public/assets/flags/no.svg new file mode 100644 index 0000000..a5f2a15 --- /dev/null +++ b/frontend/public/assets/flags/no.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/np.svg b/frontend/public/assets/flags/np.svg new file mode 100644 index 0000000..6242856 --- /dev/null +++ b/frontend/public/assets/flags/np.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/nr.svg b/frontend/public/assets/flags/nr.svg new file mode 100644 index 0000000..ff394c4 --- /dev/null +++ b/frontend/public/assets/flags/nr.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/nu.svg b/frontend/public/assets/flags/nu.svg new file mode 100644 index 0000000..4067baf --- /dev/null +++ b/frontend/public/assets/flags/nu.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/assets/flags/nz.svg b/frontend/public/assets/flags/nz.svg new file mode 100644 index 0000000..935d8a7 --- /dev/null +++ b/frontend/public/assets/flags/nz.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/om.svg b/frontend/public/assets/flags/om.svg new file mode 100644 index 0000000..4f1461a --- /dev/null +++ b/frontend/public/assets/flags/om.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/pa.svg b/frontend/public/assets/flags/pa.svg new file mode 100644 index 0000000..9ab733f --- /dev/null +++ b/frontend/public/assets/flags/pa.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/pc.svg b/frontend/public/assets/flags/pc.svg new file mode 100644 index 0000000..5202d6d --- /dev/null +++ b/frontend/public/assets/flags/pc.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/pe.svg b/frontend/public/assets/flags/pe.svg new file mode 100644 index 0000000..33e6cfd --- /dev/null +++ b/frontend/public/assets/flags/pe.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/pf.svg b/frontend/public/assets/flags/pf.svg new file mode 100644 index 0000000..bea0354 --- /dev/null +++ b/frontend/public/assets/flags/pf.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/pg.svg b/frontend/public/assets/flags/pg.svg new file mode 100644 index 0000000..7b7e77a --- /dev/null +++ b/frontend/public/assets/flags/pg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/ph.svg b/frontend/public/assets/flags/ph.svg new file mode 100644 index 0000000..b910e24 --- /dev/null +++ b/frontend/public/assets/flags/ph.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/pk.svg b/frontend/public/assets/flags/pk.svg new file mode 100644 index 0000000..4ddc19f --- /dev/null +++ b/frontend/public/assets/flags/pk.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/pl.svg b/frontend/public/assets/flags/pl.svg new file mode 100644 index 0000000..42d2b0c --- /dev/null +++ b/frontend/public/assets/flags/pl.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/pm.svg b/frontend/public/assets/flags/pm.svg new file mode 100644 index 0000000..19a9330 --- /dev/null +++ b/frontend/public/assets/flags/pm.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/pn.svg b/frontend/public/assets/flags/pn.svg new file mode 100644 index 0000000..209ea71 --- /dev/null +++ b/frontend/public/assets/flags/pn.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/pr.svg b/frontend/public/assets/flags/pr.svg new file mode 100644 index 0000000..ec51831 --- /dev/null +++ b/frontend/public/assets/flags/pr.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ps.svg b/frontend/public/assets/flags/ps.svg new file mode 100644 index 0000000..362d435 --- /dev/null +++ b/frontend/public/assets/flags/ps.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/pt.svg b/frontend/public/assets/flags/pt.svg new file mode 100644 index 0000000..2767cd4 --- /dev/null +++ b/frontend/public/assets/flags/pt.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/pw.svg b/frontend/public/assets/flags/pw.svg new file mode 100644 index 0000000..9f89c5f --- /dev/null +++ b/frontend/public/assets/flags/pw.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/assets/flags/py.svg b/frontend/public/assets/flags/py.svg new file mode 100644 index 0000000..abccd87 --- /dev/null +++ b/frontend/public/assets/flags/py.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/qa.svg b/frontend/public/assets/flags/qa.svg new file mode 100644 index 0000000..901f3fa --- /dev/null +++ b/frontend/public/assets/flags/qa.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/re.svg b/frontend/public/assets/flags/re.svg new file mode 100644 index 0000000..64e788e --- /dev/null +++ b/frontend/public/assets/flags/re.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/ro.svg b/frontend/public/assets/flags/ro.svg new file mode 100644 index 0000000..fda0f7b --- /dev/null +++ b/frontend/public/assets/flags/ro.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/rs.svg b/frontend/public/assets/flags/rs.svg new file mode 100644 index 0000000..6d4f74d --- /dev/null +++ b/frontend/public/assets/flags/rs.svg @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ru.svg b/frontend/public/assets/flags/ru.svg new file mode 100644 index 0000000..cf24301 --- /dev/null +++ b/frontend/public/assets/flags/ru.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/rw.svg b/frontend/public/assets/flags/rw.svg new file mode 100644 index 0000000..06e26ae --- /dev/null +++ b/frontend/public/assets/flags/rw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sa.svg b/frontend/public/assets/flags/sa.svg new file mode 100644 index 0000000..596cf48 --- /dev/null +++ b/frontend/public/assets/flags/sa.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sb.svg b/frontend/public/assets/flags/sb.svg new file mode 100644 index 0000000..6066f94 --- /dev/null +++ b/frontend/public/assets/flags/sb.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sc.svg b/frontend/public/assets/flags/sc.svg new file mode 100644 index 0000000..9a46b36 --- /dev/null +++ b/frontend/public/assets/flags/sc.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/sd.svg b/frontend/public/assets/flags/sd.svg new file mode 100644 index 0000000..12818b4 --- /dev/null +++ b/frontend/public/assets/flags/sd.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/se.svg b/frontend/public/assets/flags/se.svg new file mode 100644 index 0000000..8ba745a --- /dev/null +++ b/frontend/public/assets/flags/se.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/sg.svg b/frontend/public/assets/flags/sg.svg new file mode 100644 index 0000000..c4dd4ac --- /dev/null +++ b/frontend/public/assets/flags/sg.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sh-ac.svg b/frontend/public/assets/flags/sh-ac.svg new file mode 100644 index 0000000..c43b301 --- /dev/null +++ b/frontend/public/assets/flags/sh-ac.svg @@ -0,0 +1,689 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sh-hl.svg b/frontend/public/assets/flags/sh-hl.svg new file mode 100644 index 0000000..2150bf6 --- /dev/null +++ b/frontend/public/assets/flags/sh-hl.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sh-ta.svg b/frontend/public/assets/flags/sh-ta.svg new file mode 100644 index 0000000..ba39063 --- /dev/null +++ b/frontend/public/assets/flags/sh-ta.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sh.svg b/frontend/public/assets/flags/sh.svg new file mode 100644 index 0000000..7aba0ae --- /dev/null +++ b/frontend/public/assets/flags/sh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/si.svg b/frontend/public/assets/flags/si.svg new file mode 100644 index 0000000..1bbdd94 --- /dev/null +++ b/frontend/public/assets/flags/si.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sj.svg b/frontend/public/assets/flags/sj.svg new file mode 100644 index 0000000..bb2799c --- /dev/null +++ b/frontend/public/assets/flags/sj.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/sk.svg b/frontend/public/assets/flags/sk.svg new file mode 100644 index 0000000..676018e --- /dev/null +++ b/frontend/public/assets/flags/sk.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/sl.svg b/frontend/public/assets/flags/sl.svg new file mode 100644 index 0000000..a07baf7 --- /dev/null +++ b/frontend/public/assets/flags/sl.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/sm.svg b/frontend/public/assets/flags/sm.svg new file mode 100644 index 0000000..e41d2f7 --- /dev/null +++ b/frontend/public/assets/flags/sm.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sn.svg b/frontend/public/assets/flags/sn.svg new file mode 100644 index 0000000..7c0673d --- /dev/null +++ b/frontend/public/assets/flags/sn.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/so.svg b/frontend/public/assets/flags/so.svg new file mode 100644 index 0000000..a581ac6 --- /dev/null +++ b/frontend/public/assets/flags/so.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sr.svg b/frontend/public/assets/flags/sr.svg new file mode 100644 index 0000000..5e71c40 --- /dev/null +++ b/frontend/public/assets/flags/sr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/ss.svg b/frontend/public/assets/flags/ss.svg new file mode 100644 index 0000000..b257aa0 --- /dev/null +++ b/frontend/public/assets/flags/ss.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/st.svg b/frontend/public/assets/flags/st.svg new file mode 100644 index 0000000..1294bcb --- /dev/null +++ b/frontend/public/assets/flags/st.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sv.svg b/frontend/public/assets/flags/sv.svg new file mode 100644 index 0000000..cbc674a --- /dev/null +++ b/frontend/public/assets/flags/sv.svg @@ -0,0 +1,593 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sx.svg b/frontend/public/assets/flags/sx.svg new file mode 100644 index 0000000..ac78561 --- /dev/null +++ b/frontend/public/assets/flags/sx.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sy.svg b/frontend/public/assets/flags/sy.svg new file mode 100644 index 0000000..97c05cf --- /dev/null +++ b/frontend/public/assets/flags/sy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/sz.svg b/frontend/public/assets/flags/sz.svg new file mode 100644 index 0000000..eb538e4 --- /dev/null +++ b/frontend/public/assets/flags/sz.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/tc.svg b/frontend/public/assets/flags/tc.svg new file mode 100644 index 0000000..1258971 --- /dev/null +++ b/frontend/public/assets/flags/tc.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/td.svg b/frontend/public/assets/flags/td.svg new file mode 100644 index 0000000..fa3bd92 --- /dev/null +++ b/frontend/public/assets/flags/td.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/tf.svg b/frontend/public/assets/flags/tf.svg new file mode 100644 index 0000000..fba2335 --- /dev/null +++ b/frontend/public/assets/flags/tf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/tg.svg b/frontend/public/assets/flags/tg.svg new file mode 100644 index 0000000..9d6ea6c --- /dev/null +++ b/frontend/public/assets/flags/tg.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/th.svg b/frontend/public/assets/flags/th.svg new file mode 100644 index 0000000..1e93a61 --- /dev/null +++ b/frontend/public/assets/flags/th.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/tj.svg b/frontend/public/assets/flags/tj.svg new file mode 100644 index 0000000..f8c9a03 --- /dev/null +++ b/frontend/public/assets/flags/tj.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/tk.svg b/frontend/public/assets/flags/tk.svg new file mode 100644 index 0000000..05d3e86 --- /dev/null +++ b/frontend/public/assets/flags/tk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/tl.svg b/frontend/public/assets/flags/tl.svg new file mode 100644 index 0000000..3d0701a --- /dev/null +++ b/frontend/public/assets/flags/tl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/tm.svg b/frontend/public/assets/flags/tm.svg new file mode 100644 index 0000000..4154ed7 --- /dev/null +++ b/frontend/public/assets/flags/tm.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/tn.svg b/frontend/public/assets/flags/tn.svg new file mode 100644 index 0000000..5735c19 --- /dev/null +++ b/frontend/public/assets/flags/tn.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/to.svg b/frontend/public/assets/flags/to.svg new file mode 100644 index 0000000..d072337 --- /dev/null +++ b/frontend/public/assets/flags/to.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/assets/flags/tr.svg b/frontend/public/assets/flags/tr.svg new file mode 100644 index 0000000..b96da21 --- /dev/null +++ b/frontend/public/assets/flags/tr.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/tt.svg b/frontend/public/assets/flags/tt.svg new file mode 100644 index 0000000..bc24938 --- /dev/null +++ b/frontend/public/assets/flags/tt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/tv.svg b/frontend/public/assets/flags/tv.svg new file mode 100644 index 0000000..675210e --- /dev/null +++ b/frontend/public/assets/flags/tv.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/tw.svg b/frontend/public/assets/flags/tw.svg new file mode 100644 index 0000000..57fd98b --- /dev/null +++ b/frontend/public/assets/flags/tw.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/tz.svg b/frontend/public/assets/flags/tz.svg new file mode 100644 index 0000000..a2cfbca --- /dev/null +++ b/frontend/public/assets/flags/tz.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ua.svg b/frontend/public/assets/flags/ua.svg new file mode 100644 index 0000000..03daa19 --- /dev/null +++ b/frontend/public/assets/flags/ua.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/ug.svg b/frontend/public/assets/flags/ug.svg new file mode 100644 index 0000000..520eee5 --- /dev/null +++ b/frontend/public/assets/flags/ug.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/um.svg b/frontend/public/assets/flags/um.svg new file mode 100644 index 0000000..9e9edda --- /dev/null +++ b/frontend/public/assets/flags/um.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/un.svg b/frontend/public/assets/flags/un.svg new file mode 100644 index 0000000..632bbb4 --- /dev/null +++ b/frontend/public/assets/flags/un.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/us.svg b/frontend/public/assets/flags/us.svg new file mode 100644 index 0000000..9cfd0c9 --- /dev/null +++ b/frontend/public/assets/flags/us.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/uy.svg b/frontend/public/assets/flags/uy.svg new file mode 100644 index 0000000..62c36f8 --- /dev/null +++ b/frontend/public/assets/flags/uy.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/uz.svg b/frontend/public/assets/flags/uz.svg new file mode 100644 index 0000000..0ccca1b --- /dev/null +++ b/frontend/public/assets/flags/uz.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/va.svg b/frontend/public/assets/flags/va.svg new file mode 100644 index 0000000..3e297d6 --- /dev/null +++ b/frontend/public/assets/flags/va.svg @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/vc.svg b/frontend/public/assets/flags/vc.svg new file mode 100644 index 0000000..f26c2d8 --- /dev/null +++ b/frontend/public/assets/flags/vc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/ve.svg b/frontend/public/assets/flags/ve.svg new file mode 100644 index 0000000..314e7f5 --- /dev/null +++ b/frontend/public/assets/flags/ve.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/vg.svg b/frontend/public/assets/flags/vg.svg new file mode 100644 index 0000000..ac90088 --- /dev/null +++ b/frontend/public/assets/flags/vg.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/vi.svg b/frontend/public/assets/flags/vi.svg new file mode 100644 index 0000000..d88d68f --- /dev/null +++ b/frontend/public/assets/flags/vi.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/vn.svg b/frontend/public/assets/flags/vn.svg new file mode 100644 index 0000000..7e4bac8 --- /dev/null +++ b/frontend/public/assets/flags/vn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/assets/flags/vu.svg b/frontend/public/assets/flags/vu.svg new file mode 100644 index 0000000..326d29e --- /dev/null +++ b/frontend/public/assets/flags/vu.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/wf.svg b/frontend/public/assets/flags/wf.svg new file mode 100644 index 0000000..054c57d --- /dev/null +++ b/frontend/public/assets/flags/wf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/ws.svg b/frontend/public/assets/flags/ws.svg new file mode 100644 index 0000000..0e758a7 --- /dev/null +++ b/frontend/public/assets/flags/ws.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/xk.svg b/frontend/public/assets/flags/xk.svg new file mode 100644 index 0000000..0e8958d --- /dev/null +++ b/frontend/public/assets/flags/xk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/xx.svg b/frontend/public/assets/flags/xx.svg new file mode 100644 index 0000000..9333be3 --- /dev/null +++ b/frontend/public/assets/flags/xx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/ye.svg b/frontend/public/assets/flags/ye.svg new file mode 100644 index 0000000..1c9e6d6 --- /dev/null +++ b/frontend/public/assets/flags/ye.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/yt.svg b/frontend/public/assets/flags/yt.svg new file mode 100644 index 0000000..e7776b3 --- /dev/null +++ b/frontend/public/assets/flags/yt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/za.svg b/frontend/public/assets/flags/za.svg new file mode 100644 index 0000000..d563adb --- /dev/null +++ b/frontend/public/assets/flags/za.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/zm.svg b/frontend/public/assets/flags/zm.svg new file mode 100644 index 0000000..360f37a --- /dev/null +++ b/frontend/public/assets/flags/zm.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/zw.svg b/frontend/public/assets/flags/zw.svg new file mode 100644 index 0000000..93aac4f --- /dev/null +++ b/frontend/public/assets/flags/zw.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bff17ac..bb1ae62 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,9 +3,9 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Search, X, ArrowUp } from "lucide-react"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont, updateSettings } from "@/lib/settings"; +import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings"; import { applyTheme } from "@/lib/themes"; -import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetBrewPath, InstallFFmpegWithBrew } from "../wailsjs/go/main/App"; +import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetRecentFetches, SaveRecentFetches } from "../wailsjs/go/main/App"; import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { TitleBar } from "@/components/TitleBar"; @@ -35,6 +35,7 @@ import { useAvailability } from "@/hooks/useAvailability"; import { ensureApiStatusCheckStarted } from "@/lib/api-status"; import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog"; import { useDownloadProgress } from "@/hooks/useDownloadProgress"; +import { buildPlaylistFolderName } from "@/lib/playlist"; const HISTORY_KEY = "spotiflac_fetch_history"; const MAX_HISTORY = 5; function extractSpotifyEntityFromURL(url: string): { @@ -103,6 +104,25 @@ function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] { } return deduped; } +function sortHistoryItems(items: HistoryItem[]): HistoryItem[] { + return [...items].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); +} +function normalizeHistoryItems(items: HistoryItem[]): HistoryItem[] { + return dedupeHistoryItems(sortHistoryItems(items)).slice(0, MAX_HISTORY); +} +function parseStoredHistory(value: string | null): HistoryItem[] { + if (!value) { + return []; + } + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } + catch (err) { + console.error("Failed to parse stored history:", err); + return []; + } +} function App() { const [currentPage, setCurrentPage] = useState("main"); const [spotifyUrl, setSpotifyUrl] = useState(""); @@ -133,7 +153,6 @@ function App() { const downloadQueue = useDownloadQueueDialog(); const downloadProgress = useDownloadProgress(); const [isFFmpegInstalled, setIsFFmpegInstalled] = useState(null); - const [brewPath, setBrewPath] = useState(""); const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false); const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0); const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState(""); @@ -161,8 +180,6 @@ function App() { try { const installed = await CheckFFmpegInstalled(); setIsFFmpegInstalled(installed); - const brew = await GetBrewPath(); - setBrewPath(brew); } catch (err) { console.error("Failed to check FFmpeg:", err); @@ -181,7 +198,7 @@ function App() { mediaQuery.addEventListener("change", handleChange); checkForUpdates(); ensureApiStatusCheckStarted(); - loadHistory(); + void loadHistory(); const handleScroll = () => { setShowScrollTop(window.scrollY > 300); }; @@ -191,17 +208,6 @@ function App() { window.removeEventListener("scroll", handleScroll); }; }, []); - const handleEnableSpotFetchApi = async () => { - try { - await updateSettings({ useSpotFetchAPI: true }); - metadata.setShowApiModal(false); - toast.success("SpotFetch API enabled! You can now try fetching again."); - } - catch (err) { - console.error("Failed to enable SpotFetch API:", err); - toast.error("Failed to update settings"); - } - }; const scrollToTop = useCallback(() => { window.scrollTo({ top: 0, behavior: "smooth" }); }, []); @@ -231,20 +237,30 @@ function App() { console.error("Failed to check for updates:", err); } }; - const loadHistory = () => { + const persistRecentHistory = useCallback(async (history: HistoryItem[]) => { try { - const saved = localStorage.getItem(HISTORY_KEY); - if (saved) { - const deduped = dedupeHistoryItems(JSON.parse(saved)); - setFetchHistory(deduped); - localStorage.setItem(HISTORY_KEY, JSON.stringify(deduped)); - } + await SaveRecentFetches(JSON.stringify(history)); + } + catch (err) { + console.error("Failed to save recent fetches:", err); + } + }, []); + const loadHistory = useCallback(async () => { + try { + const saved = parseStoredHistory(localStorage.getItem(HISTORY_KEY)); + const persisted = parseStoredHistory(await GetRecentFetches()); + const normalized = normalizeHistoryItems([...persisted, ...saved]); + setFetchHistory(normalized); + await persistRecentHistory(normalized); } catch (err) { console.error("Failed to load history:", err); } - }; - const handleInstallFFmpeg = async (useBrew: boolean = false) => { + finally { + localStorage.removeItem(HISTORY_KEY); + } + }, [persistRecentHistory]); + const handleInstallFFmpeg = async () => { setIsInstallingFFmpeg(true); setFfmpegInstallProgress(0); setFfmpegInstallStatus("starting"); @@ -261,11 +277,11 @@ function App() { EventsOn("ffmpeg:status", (status: string) => { setFfmpegInstallStatus(status); }); - const response = useBrew ? await InstallFFmpegWithBrew() : await DownloadFFmpeg(); + const response = await DownloadFFmpeg(); EventsOff("ffmpeg:progress"); EventsOff("ffmpeg:status"); if (response.success) { - toast.success(useBrew ? "FFmpeg installed successfully via Homebrew!" : "FFmpeg installed successfully!"); + toast.success("FFmpeg installed successfully!"); setIsFFmpegInstalled(true); } else { @@ -282,14 +298,6 @@ function App() { setFfmpegInstallStatus(""); } }; - const saveHistory = (history: HistoryItem[]) => { - try { - localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); - } - catch (err) { - console.error("Failed to save history:", err); - } - }; const addToHistory = (item: Omit) => { setFetchHistory((prev) => { const normalizedUrl = normalizeHistoryURL(item.url); @@ -301,15 +309,17 @@ function App() { id: crypto.randomUUID(), timestamp: Date.now(), }; - const updated = [newItem, ...filtered].slice(0, MAX_HISTORY); - saveHistory(updated); + const updated = normalizeHistoryItems([newItem, ...filtered]); + void persistRecentHistory(updated); return updated; }); }; const removeFromHistory = (id: string) => { setFetchHistory((prev) => { + if (!prev.some((h) => h.id === id)) + return prev; const updated = prev.filter((h) => h.id !== id); - saveHistory(updated); + void persistRecentHistory(updated); return updated; }); }; @@ -413,11 +423,16 @@ function App() { if ("track" in metadata.metadata) { const { track } = metadata.metadata; const trackId = track.spotify_id || ""; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>); + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { + const artistUrl = await metadata.handleArtistClick(artist); + if (artistUrl) { + setSpotifyUrl(artistUrl); + } + }} onBack={metadata.resetMetadata}/>); } if ("album_info" in metadata.metadata) { const { album_info, track_list } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); @@ -433,7 +448,9 @@ function App() { } if ("playlist_info" in metadata.metadata) { const { playlist_info, track_list } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { + const settings = getSettings(); + const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName); + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); @@ -449,7 +466,7 @@ function App() { } if ("artist_info" in metadata.metadata) { const { artist_info, album_list, track_list } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); @@ -609,7 +626,34 @@ function App() { - + + + + Fetch Failed + + + Metadata fetch failed. Try using a high-quality VPN such as + Surfshark, ExpressVPN, Proton VPN, or a similar service. + + + Choose a location that is not blocked by Spotify or the + related service, such as the USA, UK, Germany, Netherlands, + or Singapore. + + + If you are already using a VPN, try switching to another + server and fetch again. + + + + + + + + + { }}> @@ -617,13 +661,9 @@ function App() { FFmpeg Required - {brewPath ? (<> - FFmpeg is essential for SpotiFLAC to function properly. - Homebrew detected. Recommended: brew install ffmpeg - ) : (<> - FFmpeg is essential for SpotiFLAC to function properly. - This setup will download about 100-200MB of data. - )} + SpotiFLAC checks your system for FFmpeg and FFprobe first. + If they are not available, the required binaries will be downloaded from GitHub. + This setup downloads about 30-40MB of data. @@ -651,34 +691,13 @@ function App() { )} )} - + {!isInstallingFFmpeg && ()} - {brewPath ? () : ()} - - - - - - - - SpotFetch API Recommended - - Direct fetch failed. This usually happens when your country is blocked by Spotify or your IP is restricted. Would you like to enable the SpotFetch API to bypass this? - - - - - + diff --git a/frontend/src/assets/icons/amazon-music.png b/frontend/src/assets/icons/amazon-music.png deleted file mode 100644 index 92d42cf..0000000 Binary files a/frontend/src/assets/icons/amazon-music.png and /dev/null differ diff --git a/frontend/src/assets/icons/amzn.png b/frontend/src/assets/icons/amzn.png new file mode 100644 index 0000000..e138846 Binary files /dev/null and b/frontend/src/assets/icons/amzn.png differ diff --git a/frontend/src/assets/icons/lrclib.png b/frontend/src/assets/icons/lrclib.png new file mode 100644 index 0000000..ac3e5e7 Binary files /dev/null and b/frontend/src/assets/icons/lrclib.png differ diff --git a/frontend/src/assets/icons/musicbrainz_d.png b/frontend/src/assets/icons/musicbrainz_d.png new file mode 100644 index 0000000..7467b95 Binary files /dev/null and b/frontend/src/assets/icons/musicbrainz_d.png differ diff --git a/frontend/src/assets/icons/musicbrainz_l.png b/frontend/src/assets/icons/musicbrainz_l.png new file mode 100644 index 0000000..1b864f7 Binary files /dev/null and b/frontend/src/assets/icons/musicbrainz_l.png differ diff --git a/frontend/src/assets/icons/qbz.png b/frontend/src/assets/icons/qbz.png new file mode 100644 index 0000000..a6eb75e Binary files /dev/null and b/frontend/src/assets/icons/qbz.png differ diff --git a/frontend/src/assets/icons/qobuz.png b/frontend/src/assets/icons/qobuz.png deleted file mode 100644 index d4a3be1..0000000 Binary files a/frontend/src/assets/icons/qobuz.png and /dev/null differ diff --git a/frontend/src/assets/icons/songlink.ico b/frontend/src/assets/icons/songlink.ico deleted file mode 100644 index 4fdec81..0000000 Binary files a/frontend/src/assets/icons/songlink.ico and /dev/null differ diff --git a/frontend/src/assets/icons/songlink_d.png b/frontend/src/assets/icons/songlink_d.png new file mode 100644 index 0000000..b988734 Binary files /dev/null and b/frontend/src/assets/icons/songlink_d.png differ diff --git a/frontend/src/assets/icons/songlink_l.png b/frontend/src/assets/icons/songlink_l.png new file mode 100644 index 0000000..fd0cb1a Binary files /dev/null and b/frontend/src/assets/icons/songlink_l.png differ diff --git a/frontend/src/assets/icons/songstats.png b/frontend/src/assets/icons/songstats.png index fc8a223..ae111fc 100644 Binary files a/frontend/src/assets/icons/songstats.png and b/frontend/src/assets/icons/songstats.png differ diff --git a/frontend/src/assets/icons/tidal.png b/frontend/src/assets/icons/tidal.png deleted file mode 100644 index 141e014..0000000 Binary files a/frontend/src/assets/icons/tidal.png and /dev/null differ diff --git a/frontend/src/assets/icons/tidal_d.png b/frontend/src/assets/icons/tidal_d.png new file mode 100644 index 0000000..4760bfa Binary files /dev/null and b/frontend/src/assets/icons/tidal_d.png differ diff --git a/frontend/src/assets/icons/tidal_l.png b/frontend/src/assets/icons/tidal_l.png new file mode 100644 index 0000000..7397386 Binary files /dev/null and b/frontend/src/assets/icons/tidal_l.png differ diff --git a/frontend/src/components/AboutPage.tsx b/frontend/src/components/AboutPage.tsx index 68ca93d..2b1fd56 100644 --- a/frontend/src/components/AboutPage.tsx +++ b/frontend/src/components/AboutPage.tsx @@ -197,7 +197,7 @@ export function AboutPage() { {activeTab === "projects" && (
- openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}> + openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
SpotiFLAC Next @@ -251,7 +251,7 @@ export function AboutPage() {
)}
- openExternal("https://github.com/afkarxyz/SpotiDownloader")}> + openExternal("https://github.com/spotbye/SpotiDownloader")}>
SpotiDownloader @@ -382,11 +382,10 @@ export function AboutPage() { SpotubeDL{" "} - SpotubeDL + SpotubeDL.com - Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus - with High Quality. + Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus. diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx index a71e280..1c03e6f 100644 --- a/frontend/src/components/AlbumInfo.tsx +++ b/frontend/src/components/AlbumInfo.tsx @@ -12,6 +12,7 @@ import { useState } from "react"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { joinPath, sanitizePath } from "@/lib/utils"; import { parseTemplate, type TemplateData } from "@/lib/settings"; +import { buildClickableArtists, splitArtistNames } from "@/lib/artist-links"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; interface AlbumInfoProps { albumInfo: { @@ -52,6 +53,7 @@ interface AlbumInfoProps { downloadingCoverTrack?: string | null; isBulkDownloadingCovers?: boolean; isBulkDownloadingLyrics?: boolean; + isMetadataLoading?: boolean; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; onToggleTrack: (id: string) => void; @@ -75,8 +77,52 @@ interface AlbumInfoProps { onTrackClick?: (track: TrackMetadata) => void; onBack?: () => void; } -export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) { +export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) { const settings = getSettings(); + const albumArtistNames = splitArtistNames(albumInfo.artists); + const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", "; + const fetchedTrackCount = trackList.length; + const totalTrackCount = albumInfo.total_tracks; + const showStreamingProgress = isMetadataLoading && totalTrackCount > 0 && fetchedTrackCount < totalTrackCount; + const clickableAlbumArtists = (() => { + const artistsByName = new Map(); + for (const track of trackList) { + const clickableTrackArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url); + for (const artist of clickableTrackArtists) { + const normalizedName = artist.name.trim().toLowerCase(); + if (!normalizedName || !artist.external_urls || artistsByName.has(normalizedName)) { + continue; + } + artistsByName.set(normalizedName, artist); + } + } + return albumArtistNames.map((name) => { + const normalizedName = name.trim().toLowerCase(); + const matchedArtist = artistsByName.get(normalizedName); + if (matchedArtist) { + return { + ...matchedArtist, + name, + }; + } + if (albumArtistNames.length === 1 && albumInfo.artist_id && albumInfo.artist_url) { + return { + id: albumInfo.artist_id, + name, + external_urls: albumInfo.artist_url, + }; + } + return { + id: "", + name, + external_urls: "", + }; + }); + })(); const [downloadingAlbumCover, setDownloadingAlbumCover] = useState(false); const handleDownloadAlbumCover = async () => { if (!albumInfo.images) @@ -162,18 +208,25 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT

Album

{albumInfo.name}

- {onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? ( onArtistClick({ - id: albumInfo.artist_id!, - name: albumInfo.artists, - external_urls: albumInfo.artist_url!, - })}> - {albumInfo.artists} - ) : ({albumInfo.artists})} + + {clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => ( + {onArtistClick && artist.external_urls ? ( onArtistClick({ + id: artist.id, + name: artist.name, + external_urls: artist.external_urls, + })}> + {artist.name} + ) : (artist.name)} + {index < clickableAlbumArtists.length - 1 && artistSeparator} + )) : albumInfo.artists} + {albumInfo.release_date} - {albumInfo.total_tracks.toLocaleString()} {albumInfo.total_tracks === 1 ? "track" : "tracks"} + {showStreamingProgress + ? `${fetchedTrackCount.toLocaleString()} / ${totalTrackCount.toLocaleString()} tracks` + : `${Math.max(totalTrackCount, fetchedTrackCount).toLocaleString()} ${Math.max(totalTrackCount, fetchedTrackCount) === 1 ? "track" : "tracks"}`}
diff --git a/frontend/src/components/ApiStatusTab.tsx b/frontend/src/components/ApiStatusTab.tsx index d1bb34f..674ccd4 100644 --- a/frontend/src/components/ApiStatusTab.tsx +++ b/frontend/src/components/ApiStatusTab.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button"; import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react"; -import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; +import { TidalIcon, QobuzIcon, AmazonIcon, LrclibIcon, MusicBrainzIcon } from "./PlatformIcons"; import { useApiStatus } from "@/hooks/useApiStatus"; export function ApiStatusTab() { const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus(); @@ -12,12 +12,12 @@ export function ApiStatusTab() {
-
+
{sources.map((source) => { const status = statuses[source.id] || "idle"; return (
- {source.type === "tidal" ? : source.type === "amazon" ? : } + {source.type === "tidal" ? : source.type === "amazon" ? : source.type === "lrclib" ? : source.type === "musicbrainz" ? : }

{source.name}

diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx index 987d5ca..9b77060 100644 --- a/frontend/src/components/ArtistInfo.tsx +++ b/frontend/src/components/ArtistInfo.tsx @@ -66,6 +66,7 @@ interface ArtistInfoProps { downloadingCoverTrack?: string | null; isBulkDownloadingCovers?: boolean; isBulkDownloadingLyrics?: boolean; + isMetadataLoading?: boolean; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; onToggleTrack: (id: string) => void; @@ -94,7 +95,7 @@ interface ArtistInfoProps { onTrackClick?: (track: TrackMetadata) => void; onBack?: () => void; } -export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) { +export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) { const [downloadingHeader, setDownloadingHeader] = useState(false); const [downloadingAvatar, setDownloadingAvatar] = useState(false); const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState(null); @@ -102,6 +103,17 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort const [activeTab, setActiveTab] = useState<"albums" | "tracks" | "gallery">("albums"); const [activeAlbumFilter, setActiveAlbumFilter] = useState("all"); const displayedAlbumCount = artistInfo.total_albums || albumList.length; + const fetchedAlbumCount = albumList.length; + const totalAlbumCount = artistInfo.total_albums || fetchedAlbumCount; + const totalTrackCount = albumList.reduce((sum, album) => sum + (album.total_tracks || 0), 0); + const fetchedTrackCount = trackList.length; + const albumCountLabel = isMetadataLoading && totalAlbumCount > 0 && fetchedAlbumCount < totalAlbumCount + ? `${fetchedAlbumCount.toLocaleString()} / ${totalAlbumCount.toLocaleString()} albums` + : `${displayedAlbumCount.toLocaleString()} ${displayedAlbumCount === 1 ? "album" : "albums"}`; + const resolvedTrackCount = totalTrackCount > 0 ? totalTrackCount : fetchedTrackCount; + const trackCountLabel = isMetadataLoading && totalTrackCount > 0 && fetchedTrackCount < totalTrackCount + ? `${fetchedTrackCount.toLocaleString()} / ${totalTrackCount.toLocaleString()} tracks` + : `${resolvedTrackCount.toLocaleString()} ${resolvedTrackCount === 1 ? "track" : "tracks"}`; const albumFilterCounts = useMemo(() => { const counts = new Map(); counts.set("all", (albumList || []).length); @@ -367,9 +379,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort )}
- {displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"} + {albumCountLabel} - {trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"} + {trackCountLabel} {artistInfo.genres.length > 0 && (<> {artistInfo.genres.join(", ")} @@ -420,9 +432,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort )}
- {displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"} + {albumCountLabel} - {trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"} + {trackCountLabel} {artistInfo.genres.length > 0 && (<> {artistInfo.genres.join(", ")} diff --git a/frontend/src/components/AvailabilityLinks.tsx b/frontend/src/components/AvailabilityLinks.tsx new file mode 100644 index 0000000..58791d9 --- /dev/null +++ b/frontend/src/components/AvailabilityLinks.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from "react"; +import type { TrackAvailability } from "@/types/api"; +import { openExternal } from "@/lib/utils"; +import { AmazonAvailabilityIcon, QobuzAvailabilityIcon, TidalAvailabilityIcon } from "./PlatformIcons"; +interface AvailabilityLinkEntry { + id: string; + found: boolean; + url?: string; + icon: ReactNode; +} +function getAvailabilityLinkEntries(availability: TrackAvailability): AvailabilityLinkEntry[] { + const tidalUrl = availability.tidal_url?.trim() || ""; + const qobuzUrl = availability.qobuz_url?.trim() || ""; + const amazonUrl = availability.amazon_url?.trim() || ""; + return [ + { + id: "tidal", + found: tidalUrl !== "", + url: tidalUrl, + icon: , + }, + { + id: "qobuz", + found: qobuzUrl !== "", + url: qobuzUrl, + icon: , + }, + { + id: "amazon", + found: amazonUrl !== "", + url: amazonUrl, + icon: , + }, + ]; +} +export function hasAvailabilityLinks(availability?: TrackAvailability): boolean { + if (!availability) { + return false; + } + return getAvailabilityLinkEntries(availability).some((entry) => entry.found); +} +export function AvailabilityLinks({ availability }: { + availability?: TrackAvailability; +}) { + if (!availability) { + return

Check Availability

; + } + const entries = getAvailabilityLinkEntries(availability); + return (
+ {entries.map((entry) => entry.found ? () : (
+ {entry.icon} + + Not Found + +
))} +
); +} diff --git a/frontend/src/components/FileManagerPage.tsx b/frontend/src/components/FileManagerPage.tsx index b0c8a31..5deba40 100644 --- a/frontend/src/components/FileManagerPage.tsx +++ b/frontend/src/components/FileManagerPage.tsx @@ -36,6 +36,8 @@ interface FileMetadata { track_number: number; disc_number: number; year: string; + upc?: string; + isrc?: string; } type TabType = "track" | "lyric" | "cover"; const FORMAT_PRESETS: Record -

Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}, {"{date}"}

+

Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}, {"{date}"}, {"{isrc}"}

@@ -571,7 +573,7 @@ export function FileManagerPage() {

- Preview: {renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018").replace(/\{date\}/g, "2018-02-09")}.flac + Preview: {renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018").replace(/\{date\}/g, "2018-02-09").replace(/\{isrc\}/g, "USUM71801234")}.flac

)} @@ -660,6 +662,8 @@ export function FileManagerPage() {
Track{metadataInfo.track_number || "-"}
Disc{metadataInfo.disc_number || "-"}
Year{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}
+
UPC{metadataInfo.upc || "-"}
+
ISRC{metadataInfo.isrc || "-"}
) : (
No metadata available
)} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 6378151..9004e26 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -19,7 +19,7 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) { - diff --git a/frontend/src/components/PlatformIcons.tsx b/frontend/src/components/PlatformIcons.tsx index 3e98ea0..e944837 100644 --- a/frontend/src/components/PlatformIcons.tsx +++ b/frontend/src/components/PlatformIcons.tsx @@ -1,11 +1,13 @@ -import amazonMusicIcon from "../assets/icons/amazon-music.png"; -import qobuzIcon from "../assets/icons/qobuz.png"; -import tidalIcon from "../assets/icons/tidal.png"; -const PLATFORM_ICON_URLS = { - tidal: tidalIcon, - qobuz: qobuzIcon, - amazon: amazonMusicIcon, -} as const; +import amazonMusicIcon from "../assets/icons/amzn.png"; +import lrclibIcon from "../assets/icons/lrclib.png"; +import musicBrainzDarkIcon from "../assets/icons/musicbrainz_d.png"; +import musicBrainzLightIcon from "../assets/icons/musicbrainz_l.png"; +import qobuzIcon from "../assets/icons/qbz.png"; +import songlinkDarkIcon from "../assets/icons/songlink_d.png"; +import songlinkLightIcon from "../assets/icons/songlink_l.png"; +import songstatsIcon from "../assets/icons/songstats.png"; +import tidalDarkIcon from "../assets/icons/tidal_d.png"; +import tidalLightIcon from "../assets/icons/tidal_l.png"; type PlatformIconProps = { className?: string; }; @@ -48,14 +50,48 @@ function PlatformIcon({ src, alt, className = "w-4 h-4", defaultClassName = "" } .join(" "); return {alt}; } +function ThemedPlatformIcon({ lightSrc, darkSrc, alt, className = "w-4 h-4", defaultClassName = "" }: { + lightSrc: string; + darkSrc: string; + alt: string; + className?: string; + defaultClassName?: string; +}) { + const cleanedClassName = sanitizeClassName(className); + const statusClasses = getStatusClasses(className); + const wrapperClassName = [ + cleanedClassName || "w-4 h-4", + "relative inline-flex shrink-0", + !hasRoundedClass(cleanedClassName) ? defaultClassName : "", + statusClasses, + ] + .filter(Boolean) + .join(" "); + return + + + ; +} export function TidalIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; + return ; } export function QobuzIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; + return ; } export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; + return ; +} +export function LrclibIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; +} +export function MusicBrainzIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; +} +export function SonglinkIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; +} +export function SongstatsIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; } export function TidalAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { return diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx index 50af90e..bf5f3ef 100644 --- a/frontend/src/components/PlaylistInfo.tsx +++ b/frontend/src/components/PlaylistInfo.tsx @@ -12,6 +12,7 @@ import { useState } from "react"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { joinPath, sanitizePath } from "@/lib/utils"; import { parseTemplate, type TemplateData } from "@/lib/settings"; +import { buildPlaylistFolderName } from "@/lib/playlist"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; interface PlaylistInfoProps { playlistInfo: { @@ -58,6 +59,7 @@ interface PlaylistInfoProps { downloadingCoverTrack?: string | null; isBulkDownloadingCovers?: boolean; isBulkDownloadingLyrics?: boolean; + isMetadataLoading?: boolean; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; onToggleTrack: (id: string) => void; @@ -86,9 +88,14 @@ interface PlaylistInfoProps { onTrackClick: (track: TrackMetadata) => void; onBack?: () => void; } -export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) { +export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) { const settings = getSettings(); + const playlistName = playlistInfo.owner.name; + const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName); const [downloadingPlaylistCover, setDownloadingPlaylistCover] = useState(false); + const fetchedTrackCount = trackList.length; + const totalTrackCount = playlistInfo.tracks.total; + const showStreamingProgress = isMetadataLoading && totalTrackCount > 0 && fetchedTrackCount < totalTrackCount; const handleDownloadPlaylistCover = async () => { if (!playlistInfo.cover) return; @@ -96,17 +103,16 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel try { const os = settings.operatingSystem; let outputDir = settings.downloadPath; - const playlistName = playlistInfo.owner.name; const placeholder = "__SLASH_PLACEHOLDER__"; const templateData: TemplateData = { artist: "", album: "", album_artist: "", title: playlistName.replace(/\//g, placeholder), - playlist: playlistName.replace(/\//g, placeholder), + playlist: playlistFolderName.replace(/\//g, placeholder), }; - if (settings.createPlaylistFolder && playlistName) { - outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); + if (settings.createPlaylistFolder && playlistFolderName) { + outputDir = joinPath(os, outputDir, sanitizePath(playlistFolderName.replace(/\//g, " "), os)); } if (settings.folderTemplate) { const folderPath = parseTemplate(settings.folderTemplate, templateData); @@ -157,7 +163,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
{playlistInfo.cover && (
- {playlistInfo.owner.name} + {playlistName}
@@ -172,7 +178,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel

Playlist

-

{playlistInfo.owner.name}

+

{playlistName}

{playlistInfo.description && (

{playlistInfo.description}

)}
@@ -181,7 +187,9 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
- {playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"} + {showStreamingProgress + ? `${fetchedTrackCount.toLocaleString()} / ${totalTrackCount.toLocaleString()} tracks` + : `${Math.max(totalTrackCount, fetchedTrackCount).toLocaleString()} ${Math.max(totalTrackCount, fetchedTrackCount) === 1 ? "track" : "tracks"}`} {playlistInfo.followers.total.toLocaleString()} {playlistInfo.followers.total === 1 ? "follower" : "followers"} @@ -234,7 +242,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
- +
); } diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 60b0218..04e01e7 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -13,9 +13,7 @@ import { themes, applyTheme } from "@/lib/themes"; import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { ApiStatusTab } from "./ApiStatusTab"; -import { AmazonIcon, QobuzIcon, TidalIcon } from "./PlatformIcons"; -import songlinkIcon from "@/assets/icons/songlink.ico"; -import songstatsIcon from "@/assets/icons/songstats.png"; +import { AmazonIcon, QobuzIcon, SonglinkIcon, SongstatsIcon, TidalIcon } from "./PlatformIcons"; interface SettingsPageProps { onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; onResetRequest?: (resetFn: () => void) => void; @@ -245,13 +243,13 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin - Songlink + Songlink - Songstats + Songstats @@ -568,7 +566,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin .replace(/\{track\}/g, "01") .replace(/\{disc\}/g, "1") .replace(/\{year\}/g, "2018") - .replace(/\{date\}/g, "2018-02-09")} + .replace(/\{date\}/g, "2018-02-09") + .replace(/\{isrc\}/g, "USUM71801234")} /

)} @@ -584,6 +583,16 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
+
+ setTempSettings((prev) => ({ + ...prev, + playlistOwnerFolderName: checked, + }))}/> + +
+
setTempSettings((prev) => ({ ...prev, @@ -604,6 +613,16 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
+
+ setTempSettings((prev) => ({ + ...prev, + redownloadWithSuffix: checked, + }))}/> + +
+
@@ -676,7 +695,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin .replace(/\{track\}/g, "01") .replace(/\{disc\}/g, "1") .replace(/\{year\}/g, "2018") - .replace(/\{date\}/g, "2018-02-09")} + .replace(/\{date\}/g, "2018-02-09") + .replace(/\{isrc\}/g, "USUM71801234")} .flac

)} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index b6b35e1..13e88b1 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -40,7 +40,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { } }; const handleOpenIssues = () => { - openExternal("https://github.com/afkarxyz/SpotiFLAC/issues"); + openExternal("https://github.com/spotbye/SpotiFLAC/issues"); handleIssuesDialogChange(false); }; const getAnimatedItemHandlers = (iconRef: RefObject) => ({ diff --git a/frontend/src/components/TitleBar.tsx b/frontend/src/components/TitleBar.tsx index 5929992..a238eed 100644 --- a/frontend/src/components/TitleBar.tsx +++ b/frontend/src/components/TitleBar.tsx @@ -1,31 +1,84 @@ -import { X, Minus, Maximize, SlidersHorizontal, Info, Globe } from "lucide-react"; +import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react"; import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime"; import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { getSettings, updateSettings } from "@/lib/settings"; +import { fetchCurrentIPInfo } from "@/lib/api"; +import type { CurrentIPInfo } from "@/types/api"; import { openExternal } from "@/lib/utils"; -import { useState, useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; +const IP_INFO_REFRESH_INTERVAL_MS = 30000; +const SPOTIFY_BLOCKED_COUNTRY_CODES = new Set([ + "AF", + "IO", + "CF", + "CN", + "CU", + "ER", + "IR", + "MM", + "KP", + "RU", + "SO", + "SS", + "SD", + "SY", + "TM", + "YE", +]); export function TitleBar() { - const [useSpotFetchAPI, setUseSpotFetchAPI] = useState(false); + const [currentIPInfo, setCurrentIPInfo] = useState(null); + const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false); + const [currentIPInfoError, setCurrentIPInfoError] = useState(""); + const [showIPAddress, setShowIPAddress] = useState(false); + const currentIPInfoRef = useRef(null); useEffect(() => { - const settings = getSettings(); - if (settings) { - setUseSpotFetchAPI(settings.useSpotFetchAPI || false); + currentIPInfoRef.current = currentIPInfo; + }, [currentIPInfo]); + const loadCurrentIPInfo = async (options?: { + silent?: boolean; + }) => { + const silent = options?.silent ?? false; + if (!silent) { + setIsLoadingCurrentIPInfo(true); + setCurrentIPInfoError(""); } - const handleSettingsUpdate = (event: any) => { - const updatedSettings = event.detail; - if (updatedSettings && typeof updatedSettings.useSpotFetchAPI !== 'undefined') { - setUseSpotFetchAPI(updatedSettings.useSpotFetchAPI); + try { + const info = await fetchCurrentIPInfo(); + setCurrentIPInfo(info); + setCurrentIPInfoError(""); + } + catch (error) { + if (!silent || !currentIPInfoRef.current) { + setCurrentIPInfo(null); + setCurrentIPInfoError(error instanceof Error ? error.message : "Unable to detect IP"); } - }; - window.addEventListener('settingsUpdated', handleSettingsUpdate); - return () => window.removeEventListener('settingsUpdated', handleSettingsUpdate); - }, []); - const handleSpotFetchAPIToggle = () => { - const newValue = !useSpotFetchAPI; - setUseSpotFetchAPI(newValue); - updateSettings({ useSpotFetchAPI: newValue }); + } + finally { + if (!silent) { + setIsLoadingCurrentIPInfo(false); + } + } }; + useEffect(() => { + void loadCurrentIPInfo(); + }, []); + useEffect(() => { + const intervalId = window.setInterval(() => { + void loadCurrentIPInfo({ silent: true }); + }, IP_INFO_REFRESH_INTERVAL_MS); + const handleFocus = () => { + if (document.visibilityState === "hidden") { + return; + } + void loadCurrentIPInfo({ silent: true }); + }; + window.addEventListener("focus", handleFocus); + document.addEventListener("visibilitychange", handleFocus); + return () => { + window.clearInterval(intervalId); + window.removeEventListener("focus", handleFocus); + document.removeEventListener("visibilitychange", handleFocus); + }; + }, []); const handleMinimize = () => { WindowMinimise(); }; @@ -35,6 +88,9 @@ export function TitleBar() { const handleClose = () => { Quit(); }; + const detectedCountryCode = currentIPInfo?.country_code?.toUpperCase() || ""; + const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : ""; + const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode); return (<>
@@ -46,26 +102,35 @@ export function TitleBar() { - +
- SpotFetch API - - - - - - -

Spotify Blocked Countries:

-

Afghanistan, Antarctica, Central African Republic, China, Cuba, Eritrea, Iran, Myanmar, North Korea, Russia, Somalia, South Sudan, Sudan, Syria, Turkmenistan, Yemen

-
-
-
+ Network + {isSpotifyBlockedCountry && ( + (Blocked by Spotify) + )} +
+
+
+
+ {detectedFlagPath ? ({detectedCountryCode}) : ()} + + {isLoadingCurrentIPInfo + ? "Detecting..." + : currentIPInfo + ? showIPAddress + ? `${currentIPInfo.ip} - ${currentIPInfo.country}${detectedCountryCode ? ` (${detectedCountryCode})` : ""}` + : `${currentIPInfo.country}${detectedCountryCode ? ` (${detectedCountryCode})` : ""}` + : "Unavailable"} + +
+ {currentIPInfo && !isLoadingCurrentIPInfo && ()} +
+ {!isLoadingCurrentIPInfo && !currentIPInfo && currentIPInfoError && (
+ IP detection unavailable +
)}
- - - Use SpotFetch API - {useSpotFetchAPI ? "✓" : ""} - openExternal("https://afkarxyz.qzz.io")} className="gap-2"> diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx index eac6d31..4ea8e7a 100644 --- a/frontend/src/components/TrackInfo.tsx +++ b/frontend/src/components/TrackInfo.tsx @@ -4,8 +4,9 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons"; import { usePreview } from "@/hooks/usePreview"; +import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks"; +import { buildClickableArtists } from "@/lib/artist-links"; interface TrackInfoProps { track: TrackMetadata & { album_name: string; @@ -31,10 +32,22 @@ interface TrackInfoProps { onCheckAvailability?: (spotifyId: string) => void; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; onOpenFolder: () => void; + onAlbumClick?: (album: { + id: string; + name: string; + external_urls: string; + }) => void; + onArtistClick?: (artist: { + id: string; + name: string; + external_urls: string; + }) => void; onBack?: () => void; } -export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onBack, }: TrackInfoProps) { +export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onAlbumClick, onArtistClick, onBack, }: TrackInfoProps) { const { playPreview, loadingPreview, playingTrack } = usePreview(); + const hasAlbumClick = !!(onAlbumClick && track.album_id && track.album_url); + const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url); const formatDuration = (ms: number) => { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); @@ -69,13 +82,30 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded {track.is_explicit && (E)} {isSkipped ? () : isDownloaded ? () : isFailed ? () : null}
-

{track.artists}

+

+ {clickableArtists.length > 0 ? clickableArtists.map((artist, index) => ( + {onArtistClick ? ( onArtistClick({ + id: artist.id, + name: artist.name, + external_urls: artist.external_urls, + })}> + {artist.name} + ) : (artist.name)} + {index < clickableArtists.length - 1 && ", "} + )) : track.artists} +

Album

-

{track.album_name}

+

{hasAlbumClick ? ( onAlbumClick?.({ + id: track.album_id!, + name: track.album_name, + external_urls: track.album_url!, + })}> + {track.album_name} + ) : (track.album_name)}

{track.plays && (

Total Plays

@@ -135,15 +165,11 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded {track.spotify_id && onCheckAvailability && ( - - {availability ? (
- - - -
) : (

Check Availability

)} + +
)} {isDownloaded && ( diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx index 3fefc06..c161814 100644 --- a/frontend/src/components/TrackList.tsx +++ b/frontend/src/components/TrackList.tsx @@ -5,8 +5,9 @@ import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons"; import { usePreview } from "@/hooks/usePreview"; +import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks"; +import { buildClickableArtists } from "@/lib/artist-links"; interface TrackListProps { tracks: TrackMetadata[]; searchQuery: string; @@ -172,6 +173,22 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa return plays; return num.toLocaleString(); }; + const getAvailabilityButtonIcon = (spotifyId?: string) => { + if (!spotifyId) { + return ; + } + if (checkingAvailabilityTrack === spotifyId) { + return ; + } + const availability = availabilityMap?.get(spotifyId); + if (!availability) { + return ; + } + if (hasAvailabilityLinks(availability)) { + return ; + } + return ; + }; return (
@@ -233,29 +250,22 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa {track.spotify_id && skippedTracks.has(track.spotify_id) ? () : track.spotify_id && downloadedTracks.has(track.spotify_id) ? () : track.spotify_id && failedTracks.has(track.spotify_id) ? () : null}
- {track.artists_data && track.artists_data.length > 0 ? ((() => { - const artistNames = track.artists.split(", ").map(name => name.trim()); - return artistNames.map((name, i) => { - const artistData = track.artists_data![i]; - const hasArtistData = artistData && artistData.id && artistData.external_urls; - return ( - {onArtistClick && hasArtistData ? ( onArtistClick({ - id: artistData.id, - name: name, - external_urls: artistData.external_urls, - })}> - {name} - ) : (name)} - {i < artistNames.length - 1 && ", "} - ); - }); - })()) : onArtistClick && track.artist_id && track.artist_url ? ( onArtistClick({ - id: track.artist_id!, - name: track.artists, - external_urls: track.artist_url!, - })}> - {track.artists} - ) : (track.artists)} + {(() => { + const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url); + if (clickableArtists.length === 0) { + return track.artists; + } + return clickableArtists.map((artist, i) => ( + {onArtistClick ? ( onArtistClick({ + id: artist.id, + name: artist.name, + external_urls: artist.external_urls, + })}> + {artist.name} + ) : (artist.name)} + {i < clickableArtists.length - 1 && ", "} + )); + })()}
@@ -323,15 +333,11 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa {track.spotify_id && onCheckAvailability && ( - - {availabilityMap?.has(track.spotify_id) ? (
- - - -
) : (

Check Availability

)} + +
)}
diff --git a/frontend/src/hooks/useApiStatus.ts b/frontend/src/hooks/useApiStatus.ts index 589a730..24a8b8b 100644 --- a/frontend/src/hooks/useApiStatus.ts +++ b/frontend/src/hooks/useApiStatus.ts @@ -11,6 +11,6 @@ export function useApiStatus() { return { ...state, sources: API_SOURCES, - refreshAll: checkAllApiStatuses, + refreshAll: () => checkAllApiStatuses(true), }; } diff --git a/frontend/src/hooks/useAvailability.ts b/frontend/src/hooks/useAvailability.ts index c835705..e195108 100644 --- a/frontend/src/hooks/useAvailability.ts +++ b/frontend/src/hooks/useAvailability.ts @@ -13,9 +13,6 @@ export function useAvailability() { setError("No Spotify ID provided"); return null; } - if (availabilityMap.has(spotifyId)) { - return availabilityMap.get(spotifyId)!; - } setChecking(true); setCheckingTrackId(spotifyId); setError(null); @@ -41,7 +38,7 @@ export function useAvailability() { setChecking(false); setCheckingTrackId(null); } - }, [availabilityMap]); + }, []); const getAvailability = useCallback((spotifyId: string) => { return availabilityMap.get(spotifyId); }, [availabilityMap]); diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index 0a94439..ea773c6 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -12,6 +12,7 @@ interface CheckFileExistenceRequest { album_name?: string; album_artist?: string; release_date?: string; + isrc?: string; track_number?: number; disc_number?: number; position?: number; @@ -31,6 +32,26 @@ interface FileExistenceResult { const CheckFilesExistence = (outputDir: string, rootDir: string, tracks: CheckFileExistenceRequest[]): Promise => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, rootDir, tracks); const SkipDownloadItem = (itemID: string, filePath: string): Promise => (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath); const CreateM3U8File = (playlistName: string, outputDir: string, filePaths: string[]): Promise => (window as any)["go"]["main"]["App"]["CreateM3U8File"](playlistName, outputDir, filePaths); +const GetTrackISRC = (spotifyId: string): Promise => (window as any)["go"]["main"]["App"]["GetTrackISRC"](spotifyId); +async function resolveTemplateISRC(settings: { + folderTemplate?: string; + filenameTemplate?: string; +}, spotifyId?: string): Promise { + if (!spotifyId) { + return ""; + } + const folderTemplate = settings.folderTemplate || ""; + const filenameTemplate = settings.filenameTemplate || ""; + if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) { + return ""; + } + try { + return await GetTrackISRC(spotifyId); + } + catch { + return ""; + } +} export function useDownload(region: string) { const [downloadProgress, setDownloadProgress] = useState(0); const [isDownloading, setIsDownloading] = useState(false); @@ -81,11 +102,13 @@ export function useDownload(region: string) { const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist; + const resolvedTemplateISRC = await resolveTemplateISRC(settings, spotifyId || id); const templateData: TemplateData = { artist: displayArtist?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder), album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder), + isrc: resolvedTemplateISRC?.replace(/\//g, placeholder), track: trackNumberForTemplate, year: yearValue, date: releaseDate, @@ -117,6 +140,7 @@ export function useDownload(region: string) { album_name: albumName, album_artist: displayAlbumArtist, release_date: finalReleaseDate || releaseDate, + isrc: resolvedTemplateISRC || undefined, track_number: finalTrackNumber || spotifyTrackNumber || 0, disc_number: spotifyDiscNumber || 0, position: trackNumberForTemplate, @@ -193,6 +217,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, @@ -240,6 +265,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_single_genre: settings.useSingleGenre, @@ -286,6 +312,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_single_genre: settings.useSingleGenre, @@ -350,6 +377,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_single_genre: settings.useSingleGenre, @@ -395,11 +423,13 @@ export function useDownload(region: string) { const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist; + const resolvedTemplateISRC = await resolveTemplateISRC(settings, spotifyId); const templateData: TemplateData = { artist: displayArtist?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder), album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder), + isrc: resolvedTemplateISRC?.replace(/\//g, placeholder), track: trackNumberForTemplate, year: yearValue, date: releaseDate, @@ -468,6 +498,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, @@ -515,6 +546,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, @@ -563,6 +595,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, @@ -624,6 +657,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, diff --git a/frontend/src/hooks/useLyrics.ts b/frontend/src/hooks/useLyrics.ts index 900ee3c..9c58700 100644 --- a/frontend/src/hooks/useLyrics.ts +++ b/frontend/src/hooks/useLyrics.ts @@ -5,6 +5,26 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils"; import { logger } from "@/lib/logger"; import type { TrackMetadata } from "@/types/api"; +const GetTrackISRC = (spotifyId: string): Promise => (window as any)["go"]["main"]["App"]["GetTrackISRC"](spotifyId); +async function resolveTemplateISRC(settings: { + folderTemplate?: string; + filenameTemplate?: string; +}, spotifyId?: string): Promise { + if (!spotifyId) { + return ""; + } + const folderTemplate = settings.folderTemplate || ""; + const filenameTemplate = settings.filenameTemplate || ""; + if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) { + return ""; + } + try { + return await GetTrackISRC(spotifyId); + } + catch { + return ""; + } +} export function useLyrics() { const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState(null); const [downloadedLyrics, setDownloadedLyrics] = useState>(new Set()); @@ -28,11 +48,13 @@ export function useLyrics() { const yearValue = releaseDate?.substring(0, 4); const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName; const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist; + const resolvedTemplateISRC = await resolveTemplateISRC(settings, spotifyId); const templateData: TemplateData = { artist: displayArtist?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder), album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder), + isrc: resolvedTemplateISRC?.replace(/\//g, placeholder), track: position, year: yearValue, date: releaseDate, @@ -61,6 +83,7 @@ export function useLyrics() { album_name: albumName, album_artist: displayAlbumArtist, release_date: releaseDate, + isrc: resolvedTemplateISRC || undefined, output_dir: outputDir, filename_format: settings.filenameTemplate || "{title}", track_number: settings.trackNumber, @@ -129,11 +152,13 @@ export function useLyrics() { const yearValue = track.release_date?.substring(0, 4); const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists; const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist; + const resolvedTemplateISRC = await resolveTemplateISRC(settings, id); const templateData: TemplateData = { artist: displayArtist?.replace(/\//g, placeholder), album: track.album_name?.replace(/\//g, placeholder), album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: track.name?.replace(/\//g, placeholder), + isrc: resolvedTemplateISRC?.replace(/\//g, placeholder), track: trackPosition, year: yearValue, date: track.release_date, @@ -161,6 +186,7 @@ export function useLyrics() { album_name: track.album_name, album_artist: displayAlbumArtist, release_date: track.release_date, + isrc: resolvedTemplateISRC || undefined, output_dir: outputDir, filename_format: settings.filenameTemplate || "{title}", track_number: settings.trackNumber, diff --git a/frontend/src/hooks/useMetadata.ts b/frontend/src/hooks/useMetadata.ts index 80700e7..18e57ef 100644 --- a/frontend/src/hooks/useMetadata.ts +++ b/frontend/src/hooks/useMetadata.ts @@ -1,18 +1,18 @@ import { useEffect, useRef, useState } from "react"; -import { getSettings } from "@/lib/settings"; import { fetchSpotifyMetadata } from "@/lib/api"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { logger } from "@/lib/logger"; -import { AddFetchHistory } from "../../wailsjs/go/main/App"; +import { AddFetchHistory, SearchSpotifyByType } from "../../wailsjs/go/main/App"; import { EventsOff, EventsOn } from "../../wailsjs/runtime/runtime"; import type { SpotifyMetadataResponse } from "@/types/api"; export function useMetadata() { const [loading, setLoading] = useState(false); const [metadata, setMetadata] = useState(null); + const [showVpnAdviceDialog, setShowVpnAdviceDialog] = useState(false); + const [fetchFailureReason, setFetchFailureReason] = useState(""); const loadingToastId = useRef(null); const fetchedCount = useRef(0); const currentName = useRef(""); - const [showApiModal, setShowApiModal] = useState(false); const [showAlbumDialog, setShowAlbumDialog] = useState(false); const [selectedAlbum, setSelectedAlbum] = useState<{ id: string; @@ -20,6 +20,23 @@ export function useMetadata() { external_urls: string; } | null>(null); const [pendingArtistName, setPendingArtistName] = useState(null); + const showFetchFailureAdvice = (errorMsg: string) => { + setFetchFailureReason(errorMsg); + setShowVpnAdviceDialog(true); + }; + const resolveArtistUrlBySearch = async (artistName: string): Promise => { + const query = artistName.trim(); + if (!query) { + return null; + } + const results = await SearchSpotifyByType({ + query, + search_type: "artist", + limit: 1, + offset: 0, + }); + return results[0]?.external_urls || null; + }; useEffect(() => { if (loading) { fetchedCount.current = 0; @@ -202,13 +219,8 @@ export function useMetadata() { catch (err) { const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata"; logger.error(`fetch failed: ${errorMsg}`); - const settings = getSettings(); - if (!settings.useSpotFetchAPI) { - setShowApiModal(true); - } - else { - toast.error(errorMsg); - } + toast.error(errorMsg); + showFetchFailureAdvice(errorMsg); } finally { setLoading(false); @@ -262,10 +274,17 @@ export function useMetadata() { external_urls: string; }) => { logger.debug(`artist clicked: ${artist.name}`); - const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; + const resolvedArtistUrl = artist.external_urls.trim() || (await resolveArtistUrlBySearch(artist.name)) || ""; + if (!resolvedArtistUrl) { + toast.error(`Artist not found: ${artist.name}`); + return ""; + } + const artistUrl = resolvedArtistUrl.includes("/discography") + ? resolvedArtistUrl + : resolvedArtistUrl.replace(/\/$/, "") + "/discography/all"; setPendingArtistName(artist.name); await fetchMetadataDirectly(artistUrl); - return artistUrl; + return resolvedArtistUrl; }; const handleConfirmAlbumFetch = async () => { if (!selectedAlbum) @@ -303,13 +322,8 @@ export function useMetadata() { catch (err) { const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata"; logger.error(`fetch failed: ${errorMsg}`); - const settings = getSettings(); - if (!settings.useSpotFetchAPI) { - setShowApiModal(true); - } - else { - toast.error(errorMsg); - } + toast.error(errorMsg); + showFetchFailureAdvice(errorMsg); } finally { setLoading(false); @@ -319,6 +333,9 @@ export function useMetadata() { return { loading, metadata, + showVpnAdviceDialog, + setShowVpnAdviceDialog, + fetchFailureReason, showAlbumDialog, setShowAlbumDialog, selectedAlbum, @@ -328,8 +345,6 @@ export function useMetadata() { handleConfirmAlbumFetch, handleArtistClick, loadFromCache, - showApiModal, - setShowApiModal, resetMetadata: () => setMetadata(null), }; } diff --git a/frontend/src/lib/api-status.ts b/frontend/src/lib/api-status.ts index 379e886..dfe1126 100644 --- a/frontend/src/lib/api-status.ts +++ b/frontend/src/lib/api-status.ts @@ -1,4 +1,4 @@ -import { CheckAPIStatus } from "../../wailsjs/go/main/App"; +import { CheckAPIStatus, FetchUnifiedAPIStatus } from "../../wailsjs/go/main/App"; import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout"; export type ApiCheckStatus = "checking" | "online" | "offline" | "idle"; export interface ApiSource { @@ -19,6 +19,8 @@ export const API_SOURCES: ApiSource[] = [ { id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" }, { id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.qzz.io" }, { id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.qzz.io" }, + { id: "lrclib", type: "lrclib", name: "LRCLIB", url: "https://lrclib.net" }, + { id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" }, ]; type ApiStatusState = { isCheckingAll: boolean; @@ -30,6 +32,14 @@ let apiStatusState: ApiStatusState = { }; let activeCheckAll: Promise | null = null; const listeners = new Set<() => void>(); +type SpotiFLACUnifiedStatusResponse = { + tidal?: string; + qobuz_a?: string; + qobuz_b?: string; + qobuz_c?: string; + amazon?: string; + lrclib?: string; +}; function emitApiStatusChange() { for (const listener of listeners) { listener(); @@ -39,32 +49,37 @@ function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) apiStatusState = updater(apiStatusState); emitApiStatusChange(); } -async function checkSingleApiStatus(source: ApiSource): Promise { - setApiStatusState((current) => ({ - ...current, +function statusFromUnifiedValue(value: string | undefined): ApiCheckStatus { + return value === "up" ? "online" : "offline"; +} +async function fetchUnifiedStatuses(forceRefresh: boolean): Promise> { + const response = await FetchUnifiedAPIStatus(forceRefresh); + const payload = JSON.parse(response) as SpotiFLACUnifiedStatusResponse; + const tidalStatus = statusFromUnifiedValue(payload.tidal); + return { statuses: { - ...current.statuses, - [source.id]: "checking", + tidal1: tidalStatus, + tidal2: tidalStatus, + tidal3: tidalStatus, + tidal4: tidalStatus, + tidal5: tidalStatus, + tidal6: tidalStatus, + tidal7: tidalStatus, + qobuz1: statusFromUnifiedValue(payload.qobuz_a), + qobuz2: statusFromUnifiedValue(payload.qobuz_b), + qobuz3: statusFromUnifiedValue(payload.qobuz_c), + amazon1: statusFromUnifiedValue(payload.amazon), + lrclib: statusFromUnifiedValue(payload.lrclib), }, - })); + }; +} +async function checkMusicBrainzStatus(): Promise { try { - const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.url}`); - setApiStatusState((current) => ({ - ...current, - statuses: { - ...current.statuses, - [source.id]: isOnline ? "online" : "offline", - }, - })); + const isOnline = await withTimeout(CheckAPIStatus("musicbrainz", "https://musicbrainz.org"), CHECK_TIMEOUT_MS, "API status check timed out after 10 seconds for MusicBrainz"); + return isOnline ? "online" : "offline"; } catch { - setApiStatusState((current) => ({ - ...current, - statuses: { - ...current.statuses, - [source.id]: "offline", - }, - })); + return "offline"; } } export function getApiStatusState(): ApiStatusState { @@ -84,20 +99,54 @@ export function hasApiStatusResults(): boolean { } export function ensureApiStatusCheckStarted(): void { if (!activeCheckAll && !hasApiStatusResults()) { - void checkAllApiStatuses(); + void checkAllApiStatuses(false); } } -export async function checkAllApiStatuses(): Promise { +export async function checkAllApiStatuses(forceRefresh: boolean = false): Promise { if (activeCheckAll) { return activeCheckAll; } activeCheckAll = (async () => { + const checkingStatuses = Object.fromEntries(API_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus])); setApiStatusState((current) => ({ ...current, isCheckingAll: true, + statuses: { + ...current.statuses, + ...checkingStatuses, + }, })); try { - await Promise.allSettled(API_SOURCES.map((source) => checkSingleApiStatus(source))); + const [unifiedResult, musicBrainzStatus] = await Promise.allSettled([ + withTimeout(fetchUnifiedStatuses(forceRefresh), CHECK_TIMEOUT_MS, "Unified SpotiFLAC status check timed out after 10 seconds"), + checkMusicBrainzStatus(), + ]); + setApiStatusState((current) => { + const nextStatuses = { ...current.statuses }; + if (unifiedResult.status === "fulfilled") { + Object.assign(nextStatuses, unifiedResult.value.statuses); + } + else { + nextStatuses.tidal1 = "offline"; + nextStatuses.tidal2 = "offline"; + nextStatuses.tidal3 = "offline"; + nextStatuses.tidal4 = "offline"; + nextStatuses.tidal5 = "offline"; + nextStatuses.tidal6 = "offline"; + nextStatuses.tidal7 = "offline"; + nextStatuses.qobuz1 = "offline"; + nextStatuses.qobuz2 = "offline"; + nextStatuses.qobuz3 = "offline"; + nextStatuses.amazon1 = "offline"; + nextStatuses.lrclib = "offline"; + } + nextStatuses.musicbrainz = + musicBrainzStatus.status === "fulfilled" ? musicBrainzStatus.value : "offline"; + return { + ...current, + statuses: nextStatuses, + }; + }); } finally { setApiStatusState((current) => ({ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e46a9ed..d2160f1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,5 @@ -import type { SpotifyMetadataResponse, DownloadRequest, DownloadResponse, HealthResponse, LyricsDownloadRequest, LyricsDownloadResponse, CoverDownloadRequest, CoverDownloadResponse, HeaderDownloadRequest, HeaderDownloadResponse, GalleryImageDownloadRequest, GalleryImageDownloadResponse, AvatarDownloadRequest, AvatarDownloadResponse, } from "@/types/api"; -import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover, DownloadHeader, DownloadGalleryImage, DownloadAvatar } from "../../wailsjs/go/main/App"; +import type { SpotifyMetadataResponse, DownloadRequest, DownloadResponse, HealthResponse, CurrentIPInfo, LyricsDownloadRequest, LyricsDownloadResponse, CoverDownloadRequest, CoverDownloadResponse, HeaderDownloadRequest, HeaderDownloadResponse, GalleryImageDownloadRequest, GalleryImageDownloadResponse, AvatarDownloadRequest, AvatarDownloadResponse, } from "@/types/api"; +import { GetSpotifyMetadata, GetCurrentIPInfo, DownloadTrack, DownloadLyrics, DownloadCover, DownloadHeader, DownloadGalleryImage, DownloadAvatar } from "../../wailsjs/go/main/App"; import { main } from "../../wailsjs/go/models"; export async function fetchSpotifyMetadata(url: string, batch: boolean = true, delay: number = 1.0, timeout: number = 300.0): Promise { const req = new main.SpotifyMetadataRequest({ @@ -24,6 +24,10 @@ export async function checkHealth(): Promise { time: new Date().toISOString(), }; } +export async function fetchCurrentIPInfo(): Promise { + const jsonString = await GetCurrentIPInfo(); + return JSON.parse(jsonString); +} export async function downloadLyrics(request: LyricsDownloadRequest): Promise { const req = new main.LyricsDownloadRequest(request); return await DownloadLyrics(req); diff --git a/frontend/src/lib/artist-links.ts b/frontend/src/lib/artist-links.ts new file mode 100644 index 0000000..8fc619a --- /dev/null +++ b/frontend/src/lib/artist-links.ts @@ -0,0 +1,42 @@ +import type { ArtistSimple } from "@/types/api"; +export interface ClickableArtist { + id: string; + name: string; + external_urls: string; +} +export function splitArtistNames(value: string): string[] { + const trimmed = value.trim(); + if (!trimmed) { + return []; + } + const parts = trimmed.split(/\s*[;,]\s*/).map((part) => part.trim()).filter(Boolean); + return parts.length > 0 ? parts : [trimmed]; +} +export function buildClickableArtists(artists: string, artistsData?: ArtistSimple[], fallbackArtistId?: string, fallbackArtistUrl?: string): ClickableArtist[] { + const names = splitArtistNames(artists); + if (names.length === 0) { + return []; + } + return names.map((name, index) => { + const artistData = artistsData?.[index]; + if (artistData && (artistData.id || artistData.external_urls)) { + return { + id: artistData.id || "", + name, + external_urls: artistData.external_urls || "", + }; + } + if (names.length === 1) { + return { + id: fallbackArtistId || "", + name, + external_urls: fallbackArtistUrl || "", + }; + } + return { + id: "", + name, + external_urls: "", + }; + }); +} diff --git a/frontend/src/lib/playlist.ts b/frontend/src/lib/playlist.ts new file mode 100644 index 0000000..c2338c2 --- /dev/null +++ b/frontend/src/lib/playlist.ts @@ -0,0 +1,14 @@ +export function buildPlaylistFolderName(playlistName?: string, ownerName?: string, includeOwner = false): string { + const normalizedPlaylistName = playlistName?.trim() || ""; + if (!normalizedPlaylistName) { + return ""; + } + if (!includeOwner) { + return normalizedPlaylistName; + } + const normalizedOwnerName = ownerName?.trim() || ""; + if (!normalizedOwnerName || normalizedOwnerName.toLowerCase() === normalizedPlaylistName.toLowerCase()) { + return normalizedPlaylistName; + } + return `${normalizedPlaylistName}, ${normalizedOwnerName}`; +} diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index a1e1259..291e6ec 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -28,13 +28,13 @@ export interface Settings { autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | string; autoQuality: "16" | "24"; allowFallback: boolean; - useSpotFetchAPI: boolean; - spotFetchAPIUrl: string; createPlaylistFolder: boolean; + playlistOwnerFolderName: boolean; createM3u8File: boolean; useFirstArtistOnly: boolean; useSingleGenre: boolean; embedGenre: boolean; + redownloadWithSuffix: boolean; separator: "comma" | "semicolon"; } export const FOLDER_PRESETS: Record { if (!('createPlaylistFolder' in parsed)) { parsed.createPlaylistFolder = true; } + if (!('playlistOwnerFolderName' in parsed)) { + parsed.playlistOwnerFolderName = false; + } if (!('createM3u8File' in parsed)) { parsed.createM3u8File = false; } @@ -333,11 +343,14 @@ export async function loadSettings(): Promise { parsed.useSingleGenre = false; } if (!('embedGenre' in parsed)) { - parsed.embedGenre = true; + parsed.embedGenre = false; } if (!('separator' in parsed)) { parsed.separator = "semicolon"; } + if (!('redownloadWithSuffix' in parsed)) { + parsed.redownloadWithSuffix = false; + } cachedSettings = { ...DEFAULT_SETTINGS, ...parsed }; return cachedSettings!; } @@ -360,6 +373,7 @@ export interface TemplateData { album?: string; album_artist?: string; title?: string; + isrc?: string; track?: number; disc?: number; year?: string; @@ -374,6 +388,7 @@ export function parseTemplate(template: string, data: TemplateData): string { result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist"); result = result.replace(/\{album\}/g, data.album || "Unknown Album"); result = result.replace(/\{album_artist\}/g, data.album_artist || data.artist || "Unknown Artist"); + result = result.replace(/\{isrc\}/g, data.isrc || ""); result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00"); result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1"); result = result.replace(/\{year\}/g, data.year || "0000"); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 03cd42d..48dc659 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -23,6 +23,8 @@ export interface TrackMetadata { artist_id?: string; artist_url?: string; artists_data?: ArtistSimple[]; + isrc?: string; + upc?: string; copyright?: string; publisher?: string; plays?: string; @@ -38,6 +40,7 @@ export interface AlbumInfo { release_date: string; artists: string; images: string; + upc?: string; batch?: string; } export interface AlbumResponse { @@ -116,7 +119,7 @@ export interface DownloadRequest { album_artist?: string; release_date?: string; cover_url?: string; - api_url?: string; + tidal_api_url?: string; output_dir?: string; audio_format?: string; folder_name?: string; @@ -134,6 +137,7 @@ export interface DownloadRequest { spotify_disc_number?: number; spotify_total_tracks?: number; spotify_total_discs?: number; + isrc?: string; copyright?: string; publisher?: string; spotify_url?: string; @@ -153,6 +157,12 @@ export interface HealthResponse { status: string; time: string; } +export interface CurrentIPInfo { + ip: string; + country: string; + country_code?: string; + source?: string; +} export interface TimeSlice { time: number; magnitudes: number[] | Float32Array; @@ -190,6 +200,7 @@ export interface LyricsDownloadRequest { album_name?: string; album_artist?: string; release_date?: string; + isrc?: string; output_dir?: string; filename_format?: string; track_number?: boolean; @@ -278,4 +289,6 @@ export interface AudioMetadata { track_number: number; disc_number: number; year: string; + upc?: string; + isrc?: string; } diff --git a/wails.json b/wails.json index a6b85a0..ff85543 100644 --- a/wails.json +++ b/wails.json @@ -12,7 +12,7 @@ }, "info": { "productName": "SpotiFLAC", - "productVersion": "7.1.3", + "productVersion": "7.1.4", "copyright": "© 2026 afkarxyz" }, "wailsjsdir": "./frontend",