diff --git a/README.md b/README.md index 10393ac..023dd18 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,6 @@ Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus. ## Related projects -> [!NOTE] -> -> Related projects are maintained by the community and are not affiliated with the core SpotiFLAC desktop build. - ### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile) SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet) @@ -110,7 +106,7 @@ The software is provided "as is", without warranty of any kind. The author assum ## API Credits -[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [WJHE](https://music.wjhe.top) · [GDStudio](https://music.gdstudio.xyz) · [MusicDL](https://musicdl.me) +[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [Songstats](https://songstats.com) · [hifi-api](https://github.com/binimum/hifi-api) · [Qobuz-DL](https://github.com/QobuzDL/Qobuz-DL) > [!TIP] > diff --git a/app.go b/app.go index 82a8e32..fb25d00 100644 --- a/app.go +++ b/app.go @@ -337,6 +337,7 @@ type DownloadRequest struct { ReleaseDate string `json:"release_date,omitempty"` CoverURL string `json:"cover_url,omitempty"` TidalAPIURL string `json:"tidal_api_url,omitempty"` + QobuzAPIURL string `json:"qobuz_api_url,omitempty"` OutputDir string `json:"output_dir,omitempty"` AudioFormat string `json:"audio_format,omitempty"` FilenameFormat string `json:"filename_format,omitempty"` @@ -372,6 +373,7 @@ type DownloadResponse struct { File string `json:"file,omitempty"` Error string `json:"error,omitempty"` AlreadyExists bool `json:"already_exists,omitempty"` + Cancelled bool `json:"cancelled,omitempty"` ItemID string `json:"item_id,omitempty"` } @@ -558,6 +560,20 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { backend.StartDownloadItem(itemID) defer backend.SetDownloading(false) + _, finishDownloadScope := backend.BeginDownloadCancellationScope() + defer finishDownloadScope() + + if err := backend.CheckDownloadCancelled(); err != nil { + backend.SkipDownloadItem(itemID, "") + return DownloadResponse{ + Success: false, + Message: "Download cancelled", + Error: "Download cancelled", + ItemID: itemID, + Cancelled: true, + }, nil + } + spotifyURL := "" if req.SpotifyID != "" { spotifyURL = fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID) @@ -692,10 +708,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } case "tidal": - if !strings.HasPrefix(strings.TrimRight(strings.TrimSpace(req.TidalAPIURL), "/"), "https://") { - err = fmt.Errorf("a configured HTTPS Tidal instance is required") - break - } 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, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) @@ -711,6 +723,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { isrc = <-isrcChan } downloader := backend.NewQobuzDownloader() + if strings.HasPrefix(strings.TrimRight(strings.TrimSpace(req.QobuzAPIURL), "/"), "https://") { + downloader.SetCustomAPIURL(req.QobuzAPIURL) + } quality := req.AudioFormat if quality == "" { quality = "6" @@ -725,6 +740,22 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } if err != nil { + if backend.IsDownloadCancelledError(err) { + if filename != "" && !strings.HasPrefix(filename, "EXISTS:") { + if _, statErr := os.Stat(filename); statErr == nil { + os.Remove(filename) + } + } + backend.SkipDownloadItem(itemID, "") + return DownloadResponse{ + Success: false, + Message: "Download cancelled", + Error: "Download cancelled", + ItemID: itemID, + Cancelled: true, + }, nil + } + backend.FailDownloadItem(itemID, fmt.Sprintf("Download failed: %v", err)) if filename != "" && !strings.HasPrefix(filename, "EXISTS:") { @@ -750,6 +781,20 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { filename = strings.TrimPrefix(filename, "EXISTS:") } + if !alreadyExists { + if err := backend.CheckDownloadCancelled(); err != nil { + cleanupInvalidDownloadArtifacts(filename) + backend.SkipDownloadItem(itemID, "") + return DownloadResponse{ + Success: false, + Message: "Download cancelled", + Error: "Download cancelled", + ItemID: itemID, + Cancelled: true, + }, nil + } + } + if !alreadyExists { validated, validationErr := backend.ValidateDownloadedTrackDuration(filename, req.Duration) if validationErr != nil { @@ -939,6 +984,10 @@ func (a *App) CancelAllQueuedItems() { backend.CancelAllQueuedItems() } +func (a *App) ForceStopDownloads() { + backend.ForceStopActiveDownloads() +} + func (a *App) ExportFailedDownloads() (string, error) { queueInfo := backend.GetDownloadQueue() var failedItems []string @@ -1156,6 +1205,60 @@ func (a *App) CheckCustomTidalAPI(apiURL string) bool { return false } +func (a *App) CheckCustomQobuzAPI(apiURL string) bool { + apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") + if !strings.HasPrefix(apiURL, "https://") { + return false + } + + const probeTrackID int64 = 64868955 + probeURL := fmt.Sprintf("%s/api/download-music?track_id=%d&quality=27", apiURL, probeTrackID) + + req, err := http.NewRequest(http.MethodGet, probeURL, nil) + if err != nil { + fmt.Printf("[CheckCustomQobuzAPI] Failed to create request for %s: %v\n", apiURL, err) + return false + } + 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") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + fmt.Printf("[CheckCustomQobuzAPI] Probe request failed for %s: %v\n", apiURL, err) + return false + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + fmt.Printf("[CheckCustomQobuzAPI] Failed to read probe response for %s: %v\n", apiURL, err) + return false + } + if resp.StatusCode != http.StatusOK { + fmt.Printf("[CheckCustomQobuzAPI] Probe returned status %d for %s: %s\n", resp.StatusCode, apiURL, previewResponseBody(body, 200)) + return false + } + + var probe struct { + Success bool `json:"success"` + Data struct { + URL string `json:"url"` + } `json:"data"` + } + if err := json.Unmarshal(body, &probe); err != nil { + fmt.Printf("[CheckCustomQobuzAPI] Failed to decode probe response for %s: %v\n", apiURL, err) + return false + } + if probe.Success && strings.TrimSpace(probe.Data.URL) != "" { + fmt.Printf("[CheckCustomQobuzAPI] Qobuz instance is ONLINE for %s\n", apiURL) + return true + } + + fmt.Printf("[CheckCustomQobuzAPI] Probe response was unusable for %s: %s\n", apiURL, previewResponseBody(body, 200)) + return false +} + func buildTidalStatusCheckURLs(apiURL string) []string { apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") if apiURL == "" { @@ -1288,45 +1391,6 @@ func buildGroupedAPIStatusReport(apiType string, checkURLs []string, requireAll return report } -func checkAllGroupedAPIStatus(apiType string, checkURLs []string) bool { - filtered := make([]string, 0, len(checkURLs)) - for _, rawURL := range checkURLs { - url := strings.TrimSpace(rawURL) - if url == "" { - continue - } - filtered = append(filtered, url) - } - - if len(filtered) == 0 { - return false - } - - results := make(chan bool, len(filtered)) - var wg sync.WaitGroup - - for _, checkURL := range filtered { - wg.Add(1) - go func(target string) { - defer wg.Done() - results <- checkSingleAPIStatus(apiType, target) - }(checkURL) - } - - go func() { - wg.Wait() - close(results) - }() - - for online := range results { - if !online { - return false - } - } - - return true -} - func describeAPIStatusTarget(apiType string, checkURL string) string { trimmedType := strings.TrimSpace(strings.ToLower(apiType)) trimmedURL := strings.TrimSpace(checkURL) @@ -1953,6 +2017,28 @@ func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error) return backend.ReadAudioMetadata(filePath) } +func (a *App) ReadEmbeddedLyrics(filePath string) (*backend.EmbeddedLyrics, error) { + if filePath == "" { + return nil, fmt.Errorf("file path is required") + } + return backend.ReadEmbeddedLyrics(filePath) +} + +func (a *App) ExtractLyricsToLRC(filePath string, overwrite bool) (*backend.ExtractLyricsResult, error) { + if filePath == "" { + return nil, fmt.Errorf("file path is required") + } + return backend.ExtractLyricsToLRC(filePath, overwrite) +} + +func (a *App) SelectLyricsFiles() ([]string, error) { + files, err := backend.SelectLyricsFiles(a.ctx) + if err != nil { + return nil, err + } + return files, nil +} + func (a *App) PreviewRenameFiles(files []string, format string) []backend.RenamePreview { return backend.PreviewRename(files, format) } diff --git a/backend/amazon.go b/backend/amazon.go index 207bd41..b3cdd5f 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -1,9 +1,7 @@ package backend import ( - "crypto/aes" - "crypto/cipher" - "crypto/sha256" + "bytes" "encoding/json" "fmt" "io" @@ -13,7 +11,6 @@ import ( "path/filepath" "regexp" "strings" - "sync" "time" ) @@ -22,81 +19,6 @@ type AmazonDownloader struct { regions []string } -type AmazonStreamResponse struct { - StreamURL string `json:"streamUrl"` - DecryptionKey string `json:"decryptionKey"` -} - -var ( - amazonMusicDebugKeyOnce sync.Once - amazonMusicDebugKey string - amazonMusicDebugKeyErr error -) - -var amazonMusicDebugKeySeedParts = [][]byte{ - []byte("spotif"), - []byte("lac:am"), - []byte("azon:spotbye:api:v1"), -} - -var amazonMusicDebugKeyAAD = []byte{ - 0x61, 0x6d, 0x61, 0x7a, 0x6f, 0x6e, 0x7c, 0x73, 0x70, 0x6f, 0x74, 0x62, - 0x79, 0x65, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31, -} - -var amazonMusicDebugKeyNonce = []byte{ - 0x52, 0x1f, 0xa4, 0x9c, 0x13, 0x77, 0x5b, 0xe2, 0x81, 0x44, 0x90, 0x6d, -} - -var amazonMusicDebugKeyCiphertext = []byte{ - 0x5b, 0xf9, 0xc1, 0x2e, 0x58, 0xf8, 0x5b, 0xc0, 0x04, 0x68, 0x7e, 0xff, - 0x3d, 0xd6, 0x8b, 0xe3, 0x86, 0x49, 0x6c, 0xfd, 0xc1, 0x49, 0x0b, 0xfb, -} - -var amazonMusicDebugKeyTag = []byte{ - 0x6c, 0x21, 0x98, 0x51, 0xf2, 0x38, 0x4b, 0x4a, 0x23, 0xe1, 0xc6, 0xd7, - 0x65, 0x7f, 0xfb, 0xa1, -} - -func getAmazonMusicDebugKey() (string, error) { - amazonMusicDebugKeyOnce.Do(func() { - hasher := sha256.New() - for _, part := range amazonMusicDebugKeySeedParts { - hasher.Write(part) - } - - block, err := aes.NewCipher(hasher.Sum(nil)) - if err != nil { - amazonMusicDebugKeyErr = err - return - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - amazonMusicDebugKeyErr = err - return - } - - sealed := make([]byte, 0, len(amazonMusicDebugKeyCiphertext)+len(amazonMusicDebugKeyTag)) - sealed = append(sealed, amazonMusicDebugKeyCiphertext...) - sealed = append(sealed, amazonMusicDebugKeyTag...) - - plaintext, err := gcm.Open(nil, amazonMusicDebugKeyNonce, sealed, amazonMusicDebugKeyAAD) - if err != nil { - amazonMusicDebugKeyErr = err - return - } - - amazonMusicDebugKey = string(plaintext) - }) - - if amazonMusicDebugKeyErr != nil { - return "", amazonMusicDebugKeyErr - } - - return amazonMusicDebugKey, nil -} - func NewAmazonDownloader() *AmazonDownloader { return &AmazonDownloader{ client: &http.Client{ @@ -122,7 +44,29 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin return amazonURL, nil } -func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) { +type amazonCommunityResponse struct { + ASIN string `json:"asin"` + Codec string `json:"codec"` + BitDepth int `json:"bit_depth"` + URL string `json:"url"` + StreamURL string `json:"stream_url"` + Key string `json:"key"` + KeySpecs []string `json:"key_specs"` + Captcha string `json:"captcha"` +} + +func amazonCommunityNormalizeQuality(quality string) string { + switch strings.ToLower(strings.TrimSpace(quality)) { + case "16", "lossless", "cd": + return "16" + case "atmos", "eac3", "dolby": + return "atmos" + default: + return "24" + } +} + +func (a *AmazonDownloader) downloadFromCommunity(amazonURL, outputDir, quality string) (string, error) { asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`) asin := asinRegex.FindString(amazonURL) @@ -130,20 +74,28 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL) } - apiURL := fmt.Sprintf("%s/api/track/%s", amazonMusicAPIBaseURL, asin) - req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil) + payload, err := json.Marshal(map[string]string{ + "id": asin, + "quality": amazonCommunityNormalizeQuality(quality), + "country": "US", + }) if err != nil { return "", err } - debugKey, err := getAmazonMusicDebugKey() - if err != nil { - return "", fmt.Errorf("failed to decrypt Amazon debug key: %w", err) - } - req.Header.Set("X-Debug-Key", debugKey) - fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin) - resp, err := a.client.Do(req) + resp, err := doCommunityRequest(a.client, "Amazon", func() (*http.Request, error) { + req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetAmazonCommunityDownloadURL(), bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if err := setCommunityRequestHeaders(req); err != nil { + return nil, err + } + return req, nil + }) if err != nil { return "", err } @@ -158,29 +110,43 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st return "", err } - var apiResp AmazonStreamResponse + var apiResp amazonCommunityResponse if err := json.Unmarshal(bodyBytes, &apiResp); err != nil { return "", fmt.Errorf("failed to decode response: %w", err) } - if apiResp.StreamURL == "" { + streamURL := strings.TrimSpace(apiResp.StreamURL) + if streamURL == "" { + streamURL = strings.TrimSpace(apiResp.URL) + } + if streamURL == "" { return "", fmt.Errorf("no stream URL found in response") } - downloadURL := apiResp.StreamURL - fileName := fmt.Sprintf("%s.m4a", asin) - filePath := filepath.Join(outputDir, fileName) + keySpecs := apiResp.KeySpecs + if len(keySpecs) == 0 { + if key := strings.TrimSpace(apiResp.Key); key != "" { + keySpecs = []string{key} + } + } - out, err := os.Create(filePath) + encryptedPath := filepath.Join(outputDir, fmt.Sprintf("%s.encrypted.mp4", asin)) + out, err := os.Create(encryptedPath) if err != nil { return "", err } - defer out.Close() + defer func() { + out.Close() + os.Remove(encryptedPath) + }() - dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, downloadURL, nil) + dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, streamURL, nil) if err != nil { return "", err } + if captcha := strings.TrimSpace(apiResp.Captcha); captcha != "" { + dlReq.Header.Set("x-captcha-token", captcha) + } dlResp, err := a.client.Do(dlReq) if err != nil { @@ -188,101 +154,85 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st } defer dlResp.Body.Close() - fmt.Printf("Downloading track: %s\n", fileName) + fmt.Printf("Downloading track: %s\n", asin) pw := NewProgressWriter(out) - _, err = io.Copy(pw, dlResp.Body) - if err != nil { - out.Close() - os.Remove(filePath) + if _, err = io.Copy(pw, dlResp.Body); err != nil { return "", err } + out.Close() fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) - if apiResp.DecryptionKey != "" { + remuxInput := encryptedPath + if len(keySpecs) > 0 { fmt.Printf("Decrypting file...\n") - - ffprobePath, err := GetFFprobePath() - var codec string - if err == nil { - cmdProbe := exec.Command(ffprobePath, - "-v", "quiet", - "-select_streams", "a:0", - "-show_entries", "stream=codec_name", - "-of", "default=noprint_wrappers=1:nokey=1", - filePath, - ) - setHideWindow(cmdProbe) - codecOutput, _ := cmdProbe.Output() - codec = strings.TrimSpace(string(codecOutput)) - fmt.Printf("Detected codec: %s\n", codec) + decryptedPath := filepath.Join(outputDir, fmt.Sprintf("%s.decrypted.mp4", asin)) + if err := decryptWithMP4FF(keySpecs, encryptedPath, decryptedPath); err != nil { + return "", err } - - targetExt := ".m4a" - if codec == "flac" { - targetExt = ".flac" - } - - decryptedFilename := "dec_" + fileName + targetExt - - if targetExt == ".flac" && strings.HasSuffix(fileName, ".m4a") { - decryptedFilename = "dec_" + strings.TrimSuffix(fileName, ".m4a") + ".flac" - } - - decryptedPath := filepath.Join(outputDir, decryptedFilename) - - ffmpegPath, err := GetFFmpegPath() - if err != nil { - return "", fmt.Errorf("ffmpeg not found for decryption: %w", err) - } - - if err := ValidateExecutable(ffmpegPath); err != nil { - return "", fmt.Errorf("invalid ffmpeg executable: %w", err) - } - - key := strings.TrimSpace(apiResp.DecryptionKey) - - cmd := exec.Command(ffmpegPath, - "-decryption_key", key, - "-i", filePath, - "-c", "copy", - "-y", - decryptedPath, - ) - - setHideWindow(cmd) - output, err := cmd.CombinedOutput() - if err != nil { - - outStr := string(output) - if len(outStr) > 500 { - outStr = outStr[len(outStr)-500:] - } - return "", fmt.Errorf("ffmpeg decryption failed: %v\nTail Output: %s", err, outStr) - } - - if info, err := os.Stat(decryptedPath); err != nil || info.Size() == 0 { - return "", fmt.Errorf("decrypted file missing or empty") - } - - if err := os.Remove(filePath); err != nil { - fmt.Printf("Warning: Failed to remove encrypted file: %v\n", err) - } - - finalPath := filepath.Join(outputDir, strings.TrimPrefix(decryptedFilename, "dec_")) - if err := os.Rename(decryptedPath, finalPath); err != nil { - return "", fmt.Errorf("failed to rename decrypted file: %w", err) - } - filePath = finalPath - + defer os.Remove(decryptedPath) + remuxInput = decryptedPath fmt.Println("Decryption successful") } - return filePath, nil + targetExt := ".flac" + if codec := strings.ToLower(strings.TrimSpace(apiResp.Codec)); codec == "eac3" || codec == "ec-3" || codec == "ac-3" { + targetExt = ".m4a" + } + finalPath := filepath.Join(outputDir, asin+targetExt) + + if err := amazonRemuxWithFFmpeg(remuxInput, finalPath, targetExt); err != nil { + return "", err + } + + if info, err := os.Stat(finalPath); err != nil || info.Size() == 0 { + return "", fmt.Errorf("remuxed file missing or empty") + } + + return finalPath, nil +} + +func amazonRemuxWithFFmpeg(inputPath, outputPath, targetExt string) error { + ffmpegPath, err := GetFFmpegPath() + if err != nil { + return fmt.Errorf("ffmpeg not found for remux: %w", err) + } + if err := ValidateExecutable(ffmpegPath); err != nil { + return fmt.Errorf("invalid ffmpeg executable: %w", err) + } + + runFFmpeg := func(args ...string) (string, error) { + cmd := exec.Command(ffmpegPath, args...) + setHideWindow(cmd) + output, err := cmd.CombinedOutput() + return string(output), err + } + + args := []string{"-y", "-i", inputPath, "-map", "0:a:0", "-vn", "-c:a", "copy"} + if targetExt == ".m4a" { + args = append(args, "-f", "mp4") + } + args = append(args, outputPath) + + if output, err := runFFmpeg(args...); err != nil { + if targetExt == ".flac" { + if output2, err2 := runFFmpeg("-y", "-i", inputPath, "-map", "0:a:0", "-vn", "-c:a", "flac", outputPath); err2 == nil { + return nil + } else { + output = output2 + err = err2 + } + } + if len(output) > 500 { + output = output[len(output)-500:] + } + return fmt.Errorf("ffmpeg remux failed: %v\nTail Output: %s", err, output) + } + return nil } func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) { - return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality) + return a.downloadFromCommunity(amazonURL, outputDir, quality) } func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { @@ -339,7 +289,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename fmt.Println("Fetching MusicBrainz metadata...") if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil { res.Metadata = fetchedMeta - fmt.Println("✓ MusicBrainz metadata fetched") + fmt.Println("MusicBrainz metadata fetched") } else { fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) } @@ -520,7 +470,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename } fmt.Println("Done") - fmt.Println("✓ Downloaded successfully from Amazon Music") + fmt.Println("Downloaded successfully from Amazon Music") return filePath, nil } diff --git a/backend/community_apikey.go b/backend/community_apikey.go new file mode 100644 index 0000000..72480f3 --- /dev/null +++ b/backend/community_apikey.go @@ -0,0 +1,97 @@ +package backend + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "fmt" + "net/http" + "strings" + "sync" +) + +var ( + communityAPIKeyOnce sync.Once + communityAPIKey string + communityAPIKeyErr error +) + +var communityAPIKeySeedParts = [][]byte{ + []byte("spotif"), + []byte("lac:co"), + []byte("mmunity:apikey:v1"), +} + +var communityAPIKeyAAD = []byte("spotiflac|community|apikey|v1") + +var communityAPIKeyNonce = []byte{ + 0x20, 0x5c, 0x92, 0x4b, 0x61, 0xc2, 0x79, 0xd3, 0xea, 0x5d, 0xdd, 0xd4, +} + +var communityAPIKeyCiphertext = []byte{ + 0x51, 0x0b, 0x26, 0xaf, 0xac, 0x6f, 0xf6, 0x41, 0x79, 0xde, 0x8d, 0x36, + 0x83, 0x46, 0xb5, 0xd5, 0x96, 0xef, 0xad, 0xed, 0xe0, 0xd0, 0xc7, 0xc2, + 0x90, 0x01, 0x50, 0x5f, 0x55, 0x59, 0x9f, 0xac, 0x1f, 0xd0, 0x70, 0x18, + 0x91, 0x4f, 0x7a, 0x32, +} + +var communityAPIKeyTag = []byte{ + 0x56, 0xb0, 0x28, 0x68, 0x9f, 0x39, 0x0d, 0xbc, 0xc0, 0x8e, 0xfb, 0x52, + 0x3a, 0xd6, 0x18, 0xae, +} + +func getCommunityAPIKey() (string, error) { + communityAPIKeyOnce.Do(func() { + hasher := sha256.New() + for _, part := range communityAPIKeySeedParts { + hasher.Write(part) + } + + block, err := aes.NewCipher(hasher.Sum(nil)) + if err != nil { + communityAPIKeyErr = err + return + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + communityAPIKeyErr = err + return + } + + sealed := make([]byte, 0, len(communityAPIKeyCiphertext)+len(communityAPIKeyTag)) + sealed = append(sealed, communityAPIKeyCiphertext...) + sealed = append(sealed, communityAPIKeyTag...) + + plaintext, err := gcm.Open(nil, communityAPIKeyNonce, sealed, communityAPIKeyAAD) + if err != nil { + communityAPIKeyErr = err + return + } + + communityAPIKey = string(plaintext) + }) + + if communityAPIKeyErr != nil { + return "", communityAPIKeyErr + } + return communityAPIKey, nil +} + +func communityUserAgent() string { + version := strings.TrimSpace(AppVersion) + if version == "" || version == "Unknown" { + return "SpotiFLAC" + } + return "SpotiFLAC/" + version +} + +func setCommunityRequestHeaders(req *http.Request) error { + apiKey, err := getCommunityAPIKey() + if err != nil { + return fmt.Errorf("failed to prepare community API key: %w", err) + } + req.Header.Set("x-api-key", apiKey) + req.Header.Set("User-Agent", communityUserAgent()) + return nil +} diff --git a/backend/community_endpoints.go b/backend/community_endpoints.go new file mode 100644 index 0000000..9bf9412 --- /dev/null +++ b/backend/community_endpoints.go @@ -0,0 +1,180 @@ +package backend + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +const communityDownloadPath = "/api/dl" + +var communityURLSeedParts = [][]byte{ + []byte("spotif"), + []byte("lac:co"), + []byte("mmunity:url:v1"), +} + +var communityURLAAD = []byte("spotiflac|community|url|v1") + +var ( + tidalCommunityURLNonce = []byte{ + 0x6a, 0x2a, 0x9e, 0xf3, 0x25, 0x5f, 0x48, 0x3c, 0xc3, 0xdf, 0x1d, 0xa9, + } + tidalCommunityURLCiphertext = []byte{ + 0x8f, 0x90, 0xa4, 0x28, 0x24, 0x06, 0x35, 0x13, 0x2d, 0x33, 0x96, 0x9a, + 0xd7, 0x2c, 0x31, 0x42, 0x6a, 0xf3, 0xee, 0x86, 0x34, 0x99, 0x15, 0x1e, + 0xa9, 0x07, 0x06, 0xe6, 0xee, 0x0d, 0x75, + } + tidalCommunityURLTag = []byte{ + 0x4d, 0x1c, 0x4e, 0x98, 0x96, 0x07, 0x16, 0xad, 0x6a, 0x7c, 0xa0, 0xdf, + 0xe9, 0xc5, 0xf6, 0x87, + } + + qobuzCommunityURLNonce = []byte{ + 0x5f, 0xd8, 0xfd, 0xfd, 0x89, 0x83, 0xe7, 0x6c, 0xde, 0x48, 0x47, 0x8d, + } + qobuzCommunityURLCiphertext = []byte{ + 0xfa, 0x35, 0x21, 0xba, 0x02, 0xc6, 0x15, 0x1f, 0x0e, 0xa3, 0xa6, 0x16, + 0x64, 0x2b, 0xd8, 0xfb, 0xf5, 0x35, 0xfe, 0xe9, 0x0e, 0x59, 0xd9, 0x25, + 0x72, 0x57, 0x88, 0x94, 0xa9, 0xb7, 0x70, + } + qobuzCommunityURLTag = []byte{ + 0xd7, 0x72, 0xb5, 0x2b, 0x1c, 0xb1, 0xfd, 0xba, 0x22, 0x09, 0x25, 0x41, + 0x87, 0x85, 0x30, 0x1b, + } + + amazonCommunityURLNonce = []byte{ + 0x55, 0x18, 0x01, 0x42, 0x42, 0x0c, 0xf6, 0x78, 0x8a, 0x73, 0xd7, 0x63, + } + amazonCommunityURLCiphertext = []byte{ + 0xd2, 0xf3, 0xdc, 0xe8, 0x62, 0xf0, 0xad, 0xc2, 0x4a, 0x43, 0xb1, 0xa2, + 0x1c, 0x0d, 0x41, 0x3e, 0x2e, 0x30, 0x29, 0x5e, 0x46, 0xe2, 0xc2, 0xd6, + 0xc1, 0xf3, 0xe3, 0x1a, 0x8f, 0x67, 0xfe, + } + amazonCommunityURLTag = []byte{ + 0xf9, 0x0a, 0xfd, 0xed, 0x9e, 0xe8, 0xb4, 0xc0, 0x75, 0xf3, 0xd5, 0x74, + 0x3c, 0xb6, 0xa1, 0xb9, + } +) + +var ( + communityURLGCMOnce sync.Once + communityURLGCM cipher.AEAD + communityURLGCMErr error +) + +func communityURLCipher() (cipher.AEAD, error) { + communityURLGCMOnce.Do(func() { + hasher := sha256.New() + for _, part := range communityURLSeedParts { + hasher.Write(part) + } + block, err := aes.NewCipher(hasher.Sum(nil)) + if err != nil { + communityURLGCMErr = err + return + } + gcm, err := cipher.NewGCM(block) + if err != nil { + communityURLGCMErr = err + return + } + communityURLGCM = gcm + }) + return communityURLGCM, communityURLGCMErr +} + +func decryptCommunityURL(nonce, ciphertext, tag []byte) (string, error) { + gcm, err := communityURLCipher() + if err != nil { + return "", err + } + sealed := make([]byte, 0, len(ciphertext)+len(tag)) + sealed = append(sealed, ciphertext...) + sealed = append(sealed, tag...) + plaintext, err := gcm.Open(nil, nonce, sealed, communityURLAAD) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +const communityRateLimitMaxRetries = 6 + +const communityRateLimitFallbackWait = 30 * time.Second + +func GetTidalCommunityDownloadURL() string { + base, _ := decryptCommunityURL(tidalCommunityURLNonce, tidalCommunityURLCiphertext, tidalCommunityURLTag) + return base + communityDownloadPath +} + +func GetQobuzCommunityDownloadURL() string { + base, _ := decryptCommunityURL(qobuzCommunityURLNonce, qobuzCommunityURLCiphertext, qobuzCommunityURLTag) + return base + communityDownloadPath +} + +func GetAmazonCommunityDownloadURL() string { + base, _ := decryptCommunityURL(amazonCommunityURLNonce, amazonCommunityURLCiphertext, amazonCommunityURLTag) + return base + communityDownloadPath +} + +func communityRetryAfter(resp *http.Response) time.Duration { + if resp == nil { + return communityRateLimitFallbackWait + } + if ra := strings.TrimSpace(resp.Header.Get("Retry-After")); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil && secs >= 0 { + return time.Duration(secs)*time.Second + 250*time.Millisecond + } + } + if reset := strings.TrimSpace(resp.Header.Get("X-RateLimit-Reset")); reset != "" { + if epoch, err := strconv.ParseInt(reset, 10, 64); err == nil { + if wait := time.Until(time.Unix(epoch, 0)); wait > 0 { + return wait + 250*time.Millisecond + } + } + } + return communityRateLimitFallbackWait +} + +func doCommunityRequest(client *http.Client, service string, reqFn func() (*http.Request, error)) (*http.Response, error) { + var lastErr error + for attempt := 0; attempt <= communityRateLimitMaxRetries; attempt++ { + req, err := reqFn() + if err != nil { + return nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusTooManyRequests { + ClearRateLimitCooldown() + return resp, nil + } + + wait := communityRetryAfter(resp) + resp.Body.Close() + lastErr = fmt.Errorf("%s community API rate limited (429)", service) + + if attempt == communityRateLimitMaxRetries { + break + } + fmt.Printf("%s rate limited, waiting %.0fs before retry (%d/%d)...\n", service, wait.Seconds(), attempt+1, communityRateLimitMaxRetries) + SetRateLimitCooldown(wait.Seconds()) + if sleepErr := SleepWithDownloadContext(wait); sleepErr != nil { + ClearRateLimitCooldown() + return nil, sleepErr + } + ClearRateLimitCooldown() + } + return nil, lastErr +} diff --git a/backend/download_cancel.go b/backend/download_cancel.go new file mode 100644 index 0000000..af32320 --- /dev/null +++ b/backend/download_cancel.go @@ -0,0 +1,129 @@ +package backend + +import ( + "context" + "errors" + "fmt" + "sync" + "time" +) + +var ErrDownloadCancelled = errors.New("download cancelled") + +var downloadCancelState = struct { + sync.Mutex + ctx context.Context + cancel context.CancelFunc + active int + stopping bool +}{} + +func BeginDownloadCancellationScope() (context.Context, func()) { + downloadCancelState.Lock() + defer downloadCancelState.Unlock() + + if downloadCancelState.ctx == nil || downloadCancelState.active == 0 { + downloadCancelState.ctx, downloadCancelState.cancel = context.WithCancel(context.Background()) + downloadCancelState.stopping = false + } + + downloadCancelState.active++ + ctx := downloadCancelState.ctx + once := sync.Once{} + + return ctx, func() { + once.Do(func() { + downloadCancelState.Lock() + defer downloadCancelState.Unlock() + + if downloadCancelState.active > 0 { + downloadCancelState.active-- + } + if downloadCancelState.active == 0 { + if downloadCancelState.cancel != nil { + downloadCancelState.cancel() + } + downloadCancelState.ctx = nil + downloadCancelState.cancel = nil + downloadCancelState.stopping = false + } + }) + } +} + +func ActiveDownloadContext() context.Context { + downloadCancelState.Lock() + defer downloadCancelState.Unlock() + + if downloadCancelState.ctx == nil { + return context.Background() + } + return downloadCancelState.ctx +} + +func ForceStopActiveDownloads() { + downloadCancelState.Lock() + cancel := downloadCancelState.cancel + if cancel != nil { + downloadCancelState.stopping = true + } + downloadCancelState.Unlock() + + if cancel != nil { + cancel() + } + + CancelQueuedAndDownloadingItems() + SetDownloading(false) +} + +func IsDownloadForceStopRequested() bool { + downloadCancelState.Lock() + defer downloadCancelState.Unlock() + + return downloadCancelState.stopping +} + +func CheckDownloadCancelled() error { + ctx := ActiveDownloadContext() + select { + case <-ctx.Done(): + return ErrDownloadCancelled + default: + return nil + } +} + +func SleepWithDownloadContext(delay time.Duration) error { + if delay <= 0 { + return CheckDownloadCancelled() + } + + ctx := ActiveDownloadContext() + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ErrDownloadCancelled + case <-timer.C: + return nil + } +} + +func IsDownloadCancelledError(err error) bool { + if err == nil { + return false + } + return errors.Is(err, ErrDownloadCancelled) || errors.Is(err, context.Canceled) +} + +func WrapDownloadCancelled(err error) error { + if err == nil { + return nil + } + if IsDownloadForceStopRequested() || errors.Is(err, context.Canceled) { + return fmt.Errorf("%w", ErrDownloadCancelled) + } + return err +} diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go index 04516a0..ac95223 100644 --- a/backend/ffmpeg.go +++ b/backend/ffmpeg.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "sync" "time" @@ -373,7 +374,7 @@ func InstallFFmpegWithBrew(progressCallback func(int, string)) error { return nil } -const ffmpegReleaseBaseURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.1" +const ffmpegReleaseBaseURL = "https://github.com/spotbye/Dependencies/releases/download/FFmpeg-8.1" func buildFFmpegReleaseURL(assetName string) string { return ffmpegReleaseBaseURL + "/" + assetName @@ -870,6 +871,36 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { "-map", "0:a", ) } + case "wav", "aiff": + sampleFmt, rawBits := pcmSampleFormatForInput(inputFile) + pcmCodec := "pcm_s16le" + if req.OutputFormat == "aiff" { + pcmCodec = "pcm_s16be" + } + if sampleFmt == "s32" { + if req.OutputFormat == "aiff" { + pcmCodec = "pcm_s24be" + } else { + pcmCodec = "pcm_s24le" + } + } + args = append(args, + "-codec:a", pcmCodec, + "-map", "0:a", + ) + if rawBits > 0 { + args = append(args, "-bits_per_raw_sample", strconv.Itoa(rawBits)) + } + case "opus": + bitrate := req.Bitrate + if bitrate == "" { + bitrate = "192k" + } + args = append(args, + "-codec:a", "libopus", + "-b:a", bitrate, + "-map", "0:a", + ) } args = append(args, outputFile) @@ -924,6 +955,13 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { return results, nil } +func pcmSampleFormatForInput(inputFile string) (sampleFmt string, rawBits int) { + if meta, err := GetTrackMetadata(inputFile); err == nil && meta != nil && meta.BitsPerSample > 16 { + return "s32", 24 + } + return "s16", 0 +} + type AudioFileInfo struct { Path string `json:"path"` Filename string `json:"filename"` diff --git a/backend/history.go b/backend/history.go index 18804c0..85fce8f 100644 --- a/backend/history.go +++ b/backend/history.go @@ -149,14 +149,15 @@ func ClearHistory(appName string) error { } type FetchHistoryItem struct { - ID string `json:"id"` - URL string `json:"url"` - Type string `json:"type"` - Name string `json:"name"` - Info string `json:"info"` - Image string `json:"image"` - Data string `json:"data"` - Timestamp int64 `json:"timestamp"` + ID string `json:"id"` + URL string `json:"url"` + Type string `json:"type"` + Name string `json:"name"` + Info string `json:"info"` + Image string `json:"image"` + Data string `json:"data"` + IsExplicit bool `json:"is_explicit,omitempty"` + Timestamp int64 `json:"timestamp"` } const ( diff --git a/backend/lyrics_reader.go b/backend/lyrics_reader.go new file mode 100644 index 0000000..9b797e7 --- /dev/null +++ b/backend/lyrics_reader.go @@ -0,0 +1,277 @@ +package backend + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + id3v2 "github.com/bogem/id3v2/v2" + "github.com/go-flac/flacvorbis" + "github.com/go-flac/go-flac" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +type EmbeddedLyrics struct { + Path string `json:"path"` + Name string `json:"name"` + Lyrics string `json:"lyrics"` + Source string `json:"source"` + Synced bool `json:"synced"` + Error string `json:"error,omitempty"` +} + +var lrcTimestampRe = regexp.MustCompile(`\[\d{1,2}:\d{2}(?:\.\d{1,3})?\]`) + +func isSyncedLyrics(lyrics string) bool { + return lrcTimestampRe.MatchString(lyrics) +} + +func ReadEmbeddedLyrics(filePath string) (*EmbeddedLyrics, error) { + if !fileExists(filePath) { + return nil, fmt.Errorf("file does not exist") + } + + result := &EmbeddedLyrics{ + Path: filePath, + Name: filepath.Base(filePath), + } + + ext := strings.ToLower(filepath.Ext(filePath)) + + var lyrics string + var err error + + switch ext { + case ".lrc", ".txt": + var content []byte + content, err = os.ReadFile(filePath) + if err == nil { + lyrics = string(content) + result.Source = "lrc" + } + case ".flac": + lyrics, err = readFlacLyrics(filePath) + result.Source = "embedded" + case ".mp3": + lyrics, err = readMp3Lyrics(filePath) + result.Source = "embedded" + case ".m4a", ".aac", ".opus", ".ogg": + lyrics, err = readLyricsWithFFprobe(filePath) + result.Source = "embedded" + default: + return nil, fmt.Errorf("unsupported file format: %s", ext) + } + + if err != nil { + result.Error = err.Error() + return result, nil + } + + lyrics = strings.TrimSpace(lyrics) + if lyrics == "" { + result.Error = "No lyrics found in this file" + return result, nil + } + + result.Lyrics = lyrics + result.Synced = isSyncedLyrics(lyrics) + return result, nil +} + +func readFlacLyrics(filePath string) (string, error) { + f, err := flac.ParseFile(filePath) + if err != nil { + return "", fmt.Errorf("failed to parse FLAC file: %w", err) + } + + for _, block := range f.Meta { + if block.Type != flac.VorbisComment { + continue + } + cmt, err := flacvorbis.ParseFromMetaDataBlock(*block) + if err != nil { + continue + } + for _, comment := range cmt.Comments { + parts := strings.SplitN(comment, "=", 2) + if len(parts) != 2 { + continue + } + fieldName := strings.ToUpper(parts[0]) + switch fieldName { + case "LYRICS", "UNSYNCEDLYRICS", "SYNCEDLYRICS", "LYRICS-XXX": + if strings.TrimSpace(parts[1]) != "" { + return parts[1], nil + } + } + } + } + + return "", nil +} + +func readMp3Lyrics(filePath string) (string, error) { + tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) + if err != nil { + return "", fmt.Errorf("failed to open MP3 file: %w", err) + } + defer tag.Close() + + frames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription")) + for _, frame := range frames { + uslf, ok := frame.(id3v2.UnsynchronisedLyricsFrame) + if !ok { + continue + } + if strings.TrimSpace(uslf.Lyrics) != "" { + return uslf.Lyrics, nil + } + } + + return "", nil +} + +func readLyricsWithFFprobe(filePath string) (string, error) { + ffprobePath, err := GetFFprobePath() + if err != nil { + return "", err + } + + if err := ValidateExecutable(ffprobePath); err != nil { + return "", fmt.Errorf("invalid ffprobe executable: %w", err) + } + + cmd := exec.Command(ffprobePath, + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + filePath, + ) + + setHideWindow(cmd) + + output, err := cmd.Output() + if err != nil { + return "", err + } + + var probe struct { + Format struct { + Tags map[string]string `json:"tags"` + } `json:"format"` + Streams []struct { + Tags map[string]string `json:"tags"` + } `json:"streams"` + } + + if err := json.Unmarshal(output, &probe); err != nil { + return "", err + } + + collect := func(tags map[string]string) string { + for key, value := range tags { + lk := strings.ToLower(key) + if lk == "lyrics" || strings.HasPrefix(lk, "lyrics-") || lk == "unsyncedlyrics" { + if strings.TrimSpace(value) != "" { + return value + } + } + } + return "" + } + + if lyrics := collect(probe.Format.Tags); lyrics != "" { + return lyrics, nil + } + for _, stream := range probe.Streams { + if lyrics := collect(stream.Tags); lyrics != "" { + return lyrics, nil + } + } + + return "", nil +} + +type ExtractLyricsResult struct { + Path string `json:"path"` + OutputPath string `json:"output_path,omitempty"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + AlreadyExists bool `json:"already_exists,omitempty"` +} + +func ExtractLyricsToLRC(filePath string, overwrite bool) (*ExtractLyricsResult, error) { + result := &ExtractLyricsResult{Path: filePath} + + embedded, err := ReadEmbeddedLyrics(filePath) + if err != nil { + result.Error = err.Error() + return result, nil + } + + if embedded.Error != "" { + result.Error = embedded.Error + return result, nil + } + + if strings.TrimSpace(embedded.Lyrics) == "" { + result.Error = "No lyrics found in this file" + return result, nil + } + + dir := filepath.Dir(filePath) + base := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) + outputPath := filepath.Join(dir, base+".lrc") + result.OutputPath = outputPath + + if !overwrite { + if info, statErr := os.Stat(outputPath); statErr == nil && info.Size() > 0 { + result.AlreadyExists = true + result.Error = "LRC file already exists" + return result, nil + } + } + + content := embedded.Lyrics + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + + if writeErr := os.WriteFile(outputPath, []byte(content), 0644); writeErr != nil { + result.Error = fmt.Sprintf("failed to write LRC file: %v", writeErr) + return result, nil + } + + result.Success = true + return result, nil +} + +func SelectLyricsFiles(ctx context.Context) ([]string, error) { + return runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{ + Title: "Select Lyrics or Audio Files", + Filters: []runtime.FileFilter{ + { + DisplayName: "Lyrics & Audio (*.lrc, *.flac, *.mp3, *.m4a, *.opus)", + Pattern: "*.lrc;*.flac;*.mp3;*.m4a;*.aac;*.opus;*.ogg;*.txt", + }, + { + DisplayName: "LRC Files (*.lrc)", + Pattern: "*.lrc", + }, + { + DisplayName: "Audio Files (*.flac, *.mp3, *.m4a, *.opus)", + Pattern: "*.flac;*.mp3;*.m4a;*.aac;*.opus;*.ogg", + }, + { + DisplayName: "All Files (*.*)", + Pattern: "*.*", + }, + }, + }) +} diff --git a/backend/mp4ff_decrypt.go b/backend/mp4ff_decrypt.go new file mode 100644 index 0000000..5b1db26 --- /dev/null +++ b/backend/mp4ff_decrypt.go @@ -0,0 +1,247 @@ +package backend + +import ( + "encoding/hex" + "fmt" + "io" + "os" + "strings" + + "github.com/Eyevinn/mp4ff/mp4" +) + +func decryptWithMP4FF(keySpecs []string, inputPath, outputPath string) error { + key, keysByKID, strictKIDMode, err := parseMP4FFKeySpecs(keySpecs) + if err != nil { + return err + } + + inFile, err := os.Open(inputPath) + if err != nil { + return fmt.Errorf("failed to open encrypted MP4: %w", err) + } + defer inFile.Close() + + outFile, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create decrypted MP4: %w", err) + } + outClosed := false + defer func() { + if !outClosed { + _ = outFile.Close() + } + }() + + if err := decryptMP4FFFileWithKeyMap(inFile, nil, outFile, key, keysByKID, strictKIDMode); err != nil { + _ = outFile.Close() + outClosed = true + _ = os.Remove(outputPath) + return fmt.Errorf("mp4ff decryption failed: %w", err) + } + + if err := outFile.Close(); err != nil { + outClosed = true + _ = os.Remove(outputPath) + return fmt.Errorf("failed to finalize decrypted MP4: %w", err) + } + outClosed = true + + return nil +} + +func parseMP4FFKeySpecs(keySpecs []string) (key []byte, keysByKID map[string][]byte, strictKIDMode bool, err error) { + normalizedSpecs := make([]string, 0, len(keySpecs)) + seenSpecs := make(map[string]struct{}, len(keySpecs)) + for _, spec := range keySpecs { + normalized, err := normalizeMP4FFKeySpec(spec) + if err != nil { + return nil, nil, false, err + } + if normalized == "" { + continue + } + if _, ok := seenSpecs[normalized]; ok { + continue + } + seenSpecs[normalized] = struct{}{} + normalizedSpecs = append(normalizedSpecs, normalized) + } + + if len(normalizedSpecs) == 0 { + return nil, nil, false, fmt.Errorf("no mp4ff key specs provided") + } + + hasKIDPair := false + hasLegacyKey := false + for _, spec := range normalizedSpecs { + if strings.Contains(spec, ":") { + hasKIDPair = true + } else { + hasLegacyKey = true + } + } + + if hasKIDPair && hasLegacyKey { + return nil, nil, false, fmt.Errorf("cannot mix legacy key and kid:key key format") + } + + if !hasKIDPair { + if len(normalizedSpecs) != 1 { + return nil, nil, false, fmt.Errorf("multiple legacy keys are not supported") + } + key, err = mp4.UnpackKey(normalizedSpecs[0]) + if err != nil { + return nil, nil, false, fmt.Errorf("unpacking key: %w", err) + } + return key, nil, false, nil + } + + keysByKID = make(map[string][]byte, len(normalizedSpecs)) + for _, spec := range normalizedSpecs { + parts := strings.SplitN(spec, ":", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { + return nil, nil, false, fmt.Errorf("bad kid:key format %q", spec) + } + + kid, err := mp4.UnpackKey(strings.TrimSpace(parts[0])) + if err != nil { + return nil, nil, false, fmt.Errorf("unpacking kid: %w", err) + } + kidHex := hex.EncodeToString(kid) + if _, exists := keysByKID[kidHex]; exists { + return nil, nil, false, fmt.Errorf("duplicate kid %s", kidHex) + } + + parsedKey, err := mp4.UnpackKey(strings.TrimSpace(parts[1])) + if err != nil { + return nil, nil, false, fmt.Errorf("unpacking key for kid %s: %w", kidHex, err) + } + keysByKID[kidHex] = parsedKey + } + + return nil, keysByKID, true, nil +} + +func normalizeMP4FFKeySpec(spec string) (string, error) { + spec = strings.TrimSpace(spec) + if spec == "" || !strings.Contains(spec, ":") { + return spec, nil + } + + parts := strings.SplitN(spec, ":", 2) + left := strings.TrimSpace(parts[0]) + right := strings.TrimSpace(parts[1]) + if left == "" || right == "" { + return "", fmt.Errorf("bad key spec %q", spec) + } + + if _, err := mp4.UnpackKey(left); err == nil { + return left + ":" + right, nil + } + if !isDecimalString(left) { + return "", fmt.Errorf("bad kid in key spec %q", spec) + } + + if _, err := mp4.UnpackKey(right); err != nil { + return "", fmt.Errorf("bad key spec %q: %w", spec, err) + } + + return right, nil +} + +func isDecimalString(value string) bool { + if value == "" { + return false + } + for _, ch := range value { + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +func decryptMP4FFFileWithKeyMap(r, initR io.Reader, w io.Writer, key []byte, keysByKID map[string][]byte, strictKIDMode bool) error { + inMp4, err := mp4.DecodeFile(r) + if err != nil { + return err + } + if !inMp4.IsFragmented() { + return fmt.Errorf("file not fragmented. Not supported") + } + + init := inMp4.Init + if inMp4.Init == nil { + if initR == nil { + return fmt.Errorf("no init segment file and no init part of file") + } + initSegment, err := mp4.DecodeFile(initR) + if err != nil { + return fmt.Errorf("could not decode init file: %w", err) + } + init = initSegment.Init + } + + decryptInfo, err := mp4.DecryptInit(init) + if err != nil { + return err + } + + if inMp4.Init != nil { + if err := inMp4.Init.Encode(w); err != nil { + return err + } + } + + for _, segment := range inMp4.Segments { + if inMp4.Init == nil { + if err := segment.ParseSenc(init); err != nil { + return fmt.Errorf("parseSenc: %w", err) + } + } + + if err := decryptMP4FFSegmentWithSparseSenc(segment, decryptInfo, key, keysByKID, strictKIDMode); err != nil { + return fmt.Errorf("decryptSegment: %w", err) + } + if err := segment.Encode(w); err != nil { + return err + } + } + + return nil +} + +func decryptMP4FFSegmentWithSparseSenc(segment *mp4.MediaSegment, decryptInfo mp4.DecryptInfo, key []byte, keysByKID map[string][]byte, strictKIDMode bool) error { + for _, fragment := range segment.Fragments { + if !mp4FragmentContainsSenc(fragment) { + continue + } + if err := mp4.DecryptFragmentWithKeys(fragment, decryptInfo, key, keysByKID, strictKIDMode); err != nil { + return err + } + } + + if len(segment.Sidxs) > 0 { + segment.Sidx = nil + segment.Sidxs = nil + } + + return nil +} + +func mp4FragmentContainsSenc(fragment *mp4.Fragment) bool { + if fragment == nil || fragment.Moof == nil { + return false + } + for _, traf := range fragment.Moof.Trafs { + if traf == nil { + continue + } + hasSenc, _ := traf.ContainsSencBox() + if hasSenc { + return true + } + } + return false +} diff --git a/backend/progress.go b/backend/progress.go index 0a43155..6c06ed4 100644 --- a/backend/progress.go +++ b/backend/progress.go @@ -41,6 +41,9 @@ var ( currentSpeed float64 speedLock sync.RWMutex + rateLimitUntilMs int64 + rateLimitLock sync.RWMutex + downloadQueue []DownloadItem downloadQueueLock sync.RWMutex currentItemID string @@ -55,6 +58,8 @@ type ProgressInfo struct { IsDownloading bool `json:"is_downloading"` MBDownloaded float64 `json:"mb_downloaded"` SpeedMBps float64 `json:"speed_mbps"` + RateLimited bool `json:"rate_limited"` + RateLimitSecs int `json:"rate_limit_secs"` } type DownloadQueueInfo struct { @@ -82,13 +87,45 @@ func GetDownloadProgress() ProgressInfo { speed := currentSpeed speedLock.RUnlock() + rateLimitLock.RLock() + untilMs := rateLimitUntilMs + rateLimitLock.RUnlock() + + rateLimited := false + rateLimitSecs := 0 + if untilMs > 0 { + remainingMs := untilMs - getCurrentTimeMillis() + if remainingMs > 0 { + rateLimited = true + rateLimitSecs = int((remainingMs + 999) / 1000) + } + } + return ProgressInfo{ IsDownloading: downloading, MBDownloaded: progress, SpeedMBps: speed, + RateLimited: rateLimited, + RateLimitSecs: rateLimitSecs, } } +func SetRateLimitCooldown(seconds float64) { + rateLimitLock.Lock() + if seconds <= 0 { + rateLimitUntilMs = 0 + } else { + rateLimitUntilMs = getCurrentTimeMillis() + int64(seconds*1000) + } + rateLimitLock.Unlock() +} + +func ClearRateLimitCooldown() { + rateLimitLock.Lock() + rateLimitUntilMs = 0 + rateLimitLock.Unlock() +} + func SetDownloadSpeed(mbps float64) { speedLock.Lock() currentSpeed = mbps @@ -110,6 +147,7 @@ func SetDownloading(downloading bool) { SetDownloadProgress(0) SetDownloadSpeed(0) + ClearRateLimitCooldown() } } @@ -147,6 +185,10 @@ func getCurrentTimeMillis() int64 { } func (pw *ProgressWriter) Write(p []byte) (int, error) { + if err := CheckDownloadCancelled(); err != nil { + return 0, err + } + n, err := pw.writer.Write(p) pw.total += int64(n) @@ -396,6 +438,25 @@ func CancelAllQueuedItems() { } } +func CancelQueuedAndDownloadingItems() { + downloadQueueLock.Lock() + for i := range downloadQueue { + if downloadQueue[i].Status == StatusQueued || downloadQueue[i].Status == StatusDownloading { + downloadQueue[i].Status = StatusSkipped + downloadQueue[i].EndTime = time.Now().Unix() + downloadQueue[i].ErrorMessage = "Cancelled" + } + } + downloadQueueLock.Unlock() + + currentItemLock.Lock() + currentItemID = "" + currentItemLock.Unlock() + + SetDownloadProgress(0) + SetDownloadSpeed(0) +} + func ResetSessionIfComplete() { downloadQueueLock.RLock() hasActiveOrQueued := false diff --git a/backend/qobuz.go b/backend/qobuz.go index 0a6dea5..4f043a1 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -22,7 +22,12 @@ import ( ) type QobuzDownloader struct { - client *http.Client + client *http.Client + customURL string +} + +func (q *QobuzDownloader) SetCustomAPIURL(apiURL string) { + q.customURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") } type QobuzTrack struct { @@ -754,7 +759,33 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode) + if strings.TrimSpace(q.customURL) != "" { + fmt.Printf("Trying custom Qobuz instance...\n") + url, err := q.getQobuzCustomDownloadURL(trackID, qualityCode) + if err == nil { + fmt.Printf("Success (custom Qobuz instance)\n") + return url, nil + } + if IsDownloadCancelledError(err) { + return "", err + } + fmt.Printf("Custom Qobuz instance failed: %v\n", err) + if !allowFallback { + return "", err + } + + } + downloadFunc := func(qual string) (string, error) { + if url, err := q.getQobuzCommunityDownloadURL(trackID, qual); err == nil { + fmt.Printf("Success (community qbz-a)\n") + return url, nil + } else if IsDownloadCancelledError(err) { + return "", err + } else { + fmt.Printf("Community qbz-a failed: %v\n", err) + } + attemptMap := make(map[string]qobuzProviderAttempt) attemptIDs := make([]string, 0, len(GetQobuzDownloadProviderURLs())) for _, provider := range q.getQobuzDownloadProviders() { @@ -777,7 +808,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal url, err := attempt.Download() if err == nil { - fmt.Printf("✓ Success\n") + fmt.Printf("Success\n") recordProviderSuccess("qobuz", attempt.ID) return url, nil } @@ -793,27 +824,36 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal if err == nil { return url, nil } + if IsDownloadCancelledError(err) { + return "", err + } currentQuality := qualityCode if currentQuality == "27" && allowFallback { - fmt.Printf("⚠ Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n") + fmt.Printf("Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n") url, err := downloadFunc("7") if err == nil { - fmt.Println("✓ Success with fallback quality 7") + fmt.Println("Success with fallback quality 7") return url, nil } + if IsDownloadCancelledError(err) { + return "", err + } currentQuality = "7" } if currentQuality == "7" && allowFallback { - fmt.Printf("⚠ Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n") + fmt.Printf("Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n") url, err := downloadFunc("6") if err == nil { - fmt.Println("✓ Success with fallback quality 6") + fmt.Println("Success with fallback quality 6") return url, nil } + if IsDownloadCancelledError(err) { + return "", err + } } return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err) @@ -978,7 +1018,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena } else { fmt.Println("Fetching MusicBrainz metadata...") if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil { - fmt.Println("✓ MusicBrainz metadata fetched") + fmt.Println("MusicBrainz metadata fetched") metaChan <- fetchedMeta } else { fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) diff --git a/backend/qobuz_community.go b/backend/qobuz_community.go new file mode 100644 index 0000000..522e232 --- /dev/null +++ b/backend/qobuz_community.go @@ -0,0 +1,114 @@ +package backend + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +func mapQobuzQualityToCommunity(quality string) string { + switch strings.TrimSpace(quality) { + case "27", "7": + return "24" + default: + return "16" + } +} + +func (q *QobuzDownloader) getQobuzCommunityDownloadURL(trackID int64, quality string) (string, error) { + payload, err := json.Marshal(map[string]string{ + "id": fmt.Sprintf("%d", trackID), + "quality": mapQobuzQualityToCommunity(quality), + }) + if err != nil { + return "", err + } + + resp, err := doCommunityRequest(q.client, "Qobuz", func() (*http.Request, error) { + req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetQobuzCommunityDownloadURL(), bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if err := setCommunityRequestHeaders(req); err != nil { + return nil, err + } + return req, nil + }) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("qobuz community API returned status %d", resp.StatusCode) + } + + downloadURL := extractQobuzStreamingURL(body) + if downloadURL == "" { + return "", fmt.Errorf("no streamable URL in qobuz community response") + } + return downloadURL, nil +} + +func (q *QobuzDownloader) getQobuzCustomDownloadURL(trackID int64, quality string) (string, error) { + base := strings.TrimRight(strings.TrimSpace(q.customURL), "/") + if base == "" { + return "", fmt.Errorf("no custom Qobuz instance configured") + } + + qualityCode := strings.TrimSpace(quality) + switch qualityCode { + case "5", "6", "7", "27": + default: + qualityCode = "27" + } + + endpoint := fmt.Sprintf("%s/api/download-music?track_id=%d&quality=%s", base, trackID, url.QueryEscape(qualityCode)) + req, err := NewRequestWithDefaultHeaders(http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + + resp, err := q.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("qobuz custom instance returned status %d", resp.StatusCode) + } + + var parsed struct { + Success bool `json:"success"` + Data struct { + URL string `json:"url"` + } `json:"data"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return "", fmt.Errorf("failed to decode qobuz custom response: %w", err) + } + if !parsed.Success || strings.TrimSpace(parsed.Data.URL) == "" { + if strings.TrimSpace(parsed.Error) != "" { + return "", fmt.Errorf("qobuz custom instance error: %s", parsed.Error) + } + return "", fmt.Errorf("no download URL in qobuz custom response") + } + return strings.TrimSpace(parsed.Data.URL), nil +} diff --git a/backend/recent_fetches.go b/backend/recent_fetches.go index 34d30fd..fef28b7 100644 --- a/backend/recent_fetches.go +++ b/backend/recent_fetches.go @@ -11,13 +11,14 @@ import ( const recentFetchesFileName = "recent_fetches.json" type RecentFetchItem struct { - ID string `json:"id"` - URL string `json:"url"` - Type string `json:"type"` - Name string `json:"name"` - Artist string `json:"artist"` - Image string `json:"image"` - Timestamp int64 `json:"timestamp"` + ID string `json:"id"` + URL string `json:"url"` + Type string `json:"type"` + Name string `json:"name"` + Artist string `json:"artist"` + Image string `json:"image"` + IsExplicit bool `json:"is_explicit,omitempty"` + Timestamp int64 `json:"timestamp"` } var ( diff --git a/backend/songlink.go b/backend/songlink.go index 8113a21..c45bae1 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -420,17 +420,17 @@ func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse) if link, ok := resp.LinksByPlatform["tidal"]; ok && link.URL != "" && links.TidalURL == "" { links.TidalURL = strings.TrimSpace(link.URL) - fmt.Println("✓ Tidal URL found") + fmt.Println("Tidal URL found") } if link, ok := resp.LinksByPlatform["amazonMusic"]; ok && link.URL != "" && links.AmazonURL == "" { links.AmazonURL = normalizeAmazonMusicURL(link.URL) - fmt.Println("✓ Amazon URL found") + fmt.Println("Amazon URL found") } if link, ok := resp.LinksByPlatform["deezer"]; ok && link.URL != "" && links.DeezerURL == "" { links.DeezerURL = normalizeDeezerTrackURL(link.URL) - fmt.Println("✓ Deezer URL found") + fmt.Println("Deezer URL found") } } diff --git a/backend/songstats.go b/backend/songstats.go index 7c16b8b..86d0914 100644 --- a/backend/songstats.go +++ b/backend/songstats.go @@ -110,19 +110,19 @@ func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) { case strings.Contains(link, "listen.tidal.com/track"): if links.TidalURL == "" { links.TidalURL = link - fmt.Println("✓ Tidal URL found via Songstats") + fmt.Println("Tidal URL found via Songstats") } case strings.Contains(link, "music.amazon.com"): if links.AmazonURL == "" { if normalized := normalizeAmazonMusicURL(link); normalized != "" { links.AmazonURL = normalized - fmt.Println("✓ Amazon URL found via Songstats") + fmt.Println("Amazon URL found via Songstats") } } case strings.Contains(link, "deezer.com"): if links.DeezerURL == "" { links.DeezerURL = normalizeDeezerTrackURL(link) - fmt.Println("✓ Deezer URL found via Songstats") + fmt.Println("Deezer URL found via Songstats") } } } diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index 35129ef..cb0e611 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -103,6 +103,7 @@ type AlbumInfoMetadata struct { ReleaseDate string `json:"release_date"` Artists string `json:"artists"` Images string `json:"images"` + IsExplicit bool `json:"is_explicit,omitempty"` UPC string `json:"upc,omitempty"` Batch string `json:"batch,omitempty"` ArtistID string `json:"artist_id,omitempty"` @@ -162,6 +163,7 @@ type DiscographyAlbumMetadata struct { Artists string `json:"artists"` Images string `json:"images"` ExternalURL string `json:"external_urls"` + IsExplicit bool `json:"is_explicit,omitempty"` } type ArtistDiscographyPayload struct { @@ -1104,12 +1106,21 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback break } + albumExplicit := false + for _, track := range raw.Tracks { + if track.IsExplicit { + albumExplicit = true + break + } + } + info := AlbumInfoMetadata{ TotalTracks: raw.Count, Name: raw.Name, ReleaseDate: raw.ReleaseDate, Artists: raw.Artists, Images: raw.Cover, + IsExplicit: albumExplicit, UPC: raw.UPC, ArtistID: artistID, ArtistURL: artistURL, @@ -1276,8 +1287,10 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, allTracks := make([]AlbumTrackMetadata, 0) type fetchResult struct { - tracks []AlbumTrackMetadata - err error + albumID string + tracks []AlbumTrackMetadata + isExplicit bool + err error } resultsChan := make(chan fetchResult, len(raw.Discography.All)) @@ -1318,7 +1331,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, select { case <-ctx.Done(): - resultsChan <- fetchResult{err: ctx.Err()} + resultsChan <- fetchResult{albumID: albumID, err: ctx.Err()} return default: } @@ -1326,14 +1339,18 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID, nil) if err != nil { fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err) - resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}} + resultsChan <- fetchResult{albumID: albumID, tracks: []AlbumTrackMetadata{}} return } tracks := make([]AlbumTrackMetadata, 0, len(albumData.Tracks)) + albumExplicit := false for idx, tr := range albumData.Tracks { durationMS := parseDuration(tr.Duration) trackNumber := idx + 1 + if tr.IsExplicit { + albumExplicit = true + } var artistID, artistURL string if len(tr.ArtistIds) > 0 { @@ -1377,7 +1394,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, if callback != nil { callback(tracks) } - resultsChan <- fetchResult{tracks: tracks} + resultsChan <- fetchResult{albumID: albumID, tracks: tracks, isExplicit: albumExplicit} }(alb.ID, alb.Name) } @@ -1386,6 +1403,12 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, if res.err != nil { return nil, res.err } + for albumIndex := range albumList { + if albumList[albumIndex].ID == res.albumID { + albumList[albumIndex].IsExplicit = res.isExplicit + break + } + } allTracks = append(allTracks, res.tracks...) } diff --git a/backend/tidal.go b/backend/tidal.go index fec2b64..88e00b8 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -113,7 +113,7 @@ func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, fmt.Println("Fetching MusicBrainz metadata...") if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { res.Metadata = fetchedMeta - fmt.Println("✓ MusicBrainz metadata fetched") + fmt.Println("MusicBrainz metadata fetched") } else { fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) } @@ -253,7 +253,8 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { fmt.Println("Fetching URL...") if strings.TrimSpace(t.apiURL) == "" { - return "", fmt.Errorf("no configured custom tidal api instance") + fmt.Println("No custom Tidal instance configured, using community tdl-a endpoint") + return t.getTidalCommunityDownloadURL(trackID, quality) } url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality) @@ -261,31 +262,31 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil) if err != nil { - fmt.Printf("✗ failed to create request: %v\n", err) + fmt.Printf("failed to create request: %v\n", err) return "", fmt.Errorf("failed to create request: %w", err) } resp, err := t.client.Do(req) if err != nil { - fmt.Printf("✗ Tidal API request failed: %v\n", err) + fmt.Printf("Tidal API request failed: %v\n", err) return "", fmt.Errorf("failed to get download URL: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { - fmt.Printf("✗ Tidal API returned status code: %d\n", resp.StatusCode) + fmt.Printf("Tidal API returned status code: %d\n", resp.StatusCode) return "", fmt.Errorf("API returned status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { - fmt.Printf("✗ Failed to read response body: %v\n", err) + fmt.Printf("Failed to read response body: %v\n", err) return "", fmt.Errorf("failed to read response: %w", err) } var v2Response TidalAPIResponseV2 if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { - fmt.Println("✓ Tidal manifest found (v2 API)") + fmt.Println("Tidal manifest found (v2 API)") return "MANIFEST:" + v2Response.Data.Manifest, nil } @@ -296,23 +297,23 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, if len(bodyStr) > 200 { bodyStr = bodyStr[:200] + "..." } - fmt.Printf("✗ Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr) + fmt.Printf("Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr) return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) } if len(apiResponses) == 0 { - fmt.Println("✗ Tidal API returned empty response") + fmt.Println("Tidal API returned empty response") return "", fmt.Errorf("no download URL in response") } for _, item := range apiResponses { if item.OriginalTrackURL != "" { - fmt.Println("✓ Tidal download URL found") + fmt.Println("Tidal download URL found") return item.OriginalTrackURL, nil } } - fmt.Println("✗ No valid download URL in Tidal API response") + fmt.Println("No valid download URL in Tidal API response") return "", fmt.Errorf("download URL not found in response") } @@ -327,7 +328,8 @@ func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) err return fmt.Errorf("failed to create request: %w", err) } - resp, err := t.client.Do(req) + downloadClient := &http.Client{Timeout: 5 * time.Minute} + resp, err := downloadClient.Do(req) if err != nil { return fmt.Errorf("failed to download file: %w", err) @@ -570,8 +572,11 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo downloadURL, err := t.GetDownloadURL(trackID, quality) if err != nil { + if IsDownloadCancelledError(err) { + return outputFilename, err + } if isTidalHiResQuality(quality) && allowFallback { - fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...") + fmt.Println("HI_RES unavailable/failed, falling back to LOSSLESS...") downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS") if err != nil { return outputFilename, fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err) @@ -590,7 +595,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre) fmt.Println("Done") - fmt.Println("✓ Downloaded successfully from Tidal") + fmt.Println("Downloaded successfully from Tidal") return outputFilename, nil } @@ -621,12 +626,12 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality cleanupTidalDownloadArtifacts(outputFilename) return outputFilename, err } - fmt.Printf("✓ Downloaded using API: %s\n", successAPI) + fmt.Printf("Downloaded using API: %s\n", successAPI) finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre) fmt.Println("Done") - fmt.Println("✓ Downloaded successfully from Tidal") + fmt.Println("Downloaded successfully from Tidal") return outputFilename, nil } @@ -637,9 +642,6 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err) } - if t.apiURL == "" { - return "", fmt.Errorf("no configured custom tidal api instance") - } return t.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) } @@ -820,7 +822,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename var lastErr error for idx, candidateQuality := range qualities { if idx > 0 { - fmt.Printf("⚠ %s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality) + fmt.Printf("%s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality) } apiURL, err := t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, candidateQuality, false) @@ -875,7 +877,7 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena fmt.Println("All Tidal APIs failed:") for _, item := range errors { - fmt.Printf(" ✗ %s\n", item) + fmt.Printf(" %s\n", item) } return "", fmt.Errorf("all tidal apis failed for quality %s: %w", quality, lastErr) diff --git a/backend/tidal_community.go b/backend/tidal_community.go new file mode 100644 index 0000000..f8e8ee3 --- /dev/null +++ b/backend/tidal_community.go @@ -0,0 +1,80 @@ +package backend + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type tidalCommunityResponse struct { + Quality string `json:"quality"` + URL string `json:"url"` + Lyric string `json:"lyric"` +} + +var tidalCommunityClient = &http.Client{Timeout: 60 * time.Second} + +func mapTidalQualityToCommunity(quality string) string { + switch strings.ToUpper(strings.TrimSpace(quality)) { + case "HI_RES_LOSSLESS", "HI_RES", "24": + return "24" + default: + return "16" + } +} + +func (t *TidalDownloader) getTidalCommunityDownloadURL(trackID int64, quality string) (string, error) { + payload, err := json.Marshal(map[string]string{ + "id": fmt.Sprintf("%d", trackID), + "quality": mapTidalQualityToCommunity(quality), + }) + if err != nil { + return "", err + } + + resp, err := doCommunityRequest(tidalCommunityClient, "Tidal", func() (*http.Request, error) { + req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetTidalCommunityDownloadURL(), bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if err := setCommunityRequestHeaders(req); err != nil { + return nil, err + } + return req, nil + }) + if err != nil { + fmt.Printf("Tidal community request failed: %v\n", err) + return "", fmt.Errorf("failed to get download URL: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + preview := string(body) + if len(preview) > 200 { + preview = preview[:200] + } + fmt.Printf("Tidal community API status %d: %s\n", resp.StatusCode, preview) + return "", fmt.Errorf("tidal community API returned status %d: %s", resp.StatusCode, preview) + } + + var parsed tidalCommunityResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return "", fmt.Errorf("failed to decode tidal community response: %w", err) + } + if strings.TrimSpace(parsed.URL) == "" { + return "", fmt.Errorf("no download URL in tidal community response") + } + fmt.Printf("Tidal community URL found (quality %s)\n", parsed.Quality) + return parsed.URL, nil +} diff --git a/frontend/scripts/generate-icon.js b/frontend/scripts/generate-icon.js index 02eec68..d3bcdf2 100644 --- a/frontend/scripts/generate-icon.js +++ b/frontend/scripts/generate-icon.js @@ -14,10 +14,10 @@ async function generateIcon() { .resize(1024, 1024) .png() .toFile(outputPath); - console.log('✓ Icon generated:', outputPath); + console.log('Icon generated:', outputPath); } catch (error) { - console.error('✗ Failed to generate icon:', error.message); + console.error('Failed to generate icon:', error.message); process.exit(1); } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e71853c..5884435 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,12 +5,14 @@ import { Search, X, ArrowUp } from "lucide-react"; import { TooltipProvider } from "@/components/ui/tooltip"; import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings"; import { applyTheme } from "@/lib/themes"; +import { openExternal } from "@/lib/utils"; 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"; import { Sidebar, type PageType } from "@/components/Sidebar"; import { Header } from "@/components/Header"; +import { MarkdownLite, extractMarkdownSection } from "@/components/MarkdownLite"; import { SearchBar } from "@/components/SearchBar"; import { TrackInfo } from "@/components/TrackInfo"; import { AlbumInfo } from "@/components/AlbumInfo"; @@ -22,6 +24,7 @@ import { AudioAnalysisPage } from "@/components/AudioAnalysisPage"; import { AudioConverterPage } from "@/components/AudioConverterPage"; import { AudioResamplerPage } from "@/components/AudioResamplerPage"; import { FileManagerPage } from "@/components/FileManagerPage"; +import { LyricsManagerPage } from "@/components/LyricsManagerPage"; import { SettingsPage } from "@/components/SettingsPage"; import { DebugLoggerPage } from "@/components/DebugLoggerPage"; import { OtherProjects } from "@/components/OtherProjects"; @@ -134,6 +137,12 @@ function App() { const [currentListPage, setCurrentListPage] = useState(1); const [hasUpdate, setHasUpdate] = useState(false); const [releaseDate, setReleaseDate] = useState(null); + const [updateInfo, setUpdateInfo] = useState<{ + version: string; + changelog: string; + url: string; + } | null>(null); + const [showUpdateDialog, setShowUpdateDialog] = useState(false); const [fetchHistory, setFetchHistory] = useState([]); const [isSearchMode, setIsSearchMode] = useState(false); const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US"); @@ -238,14 +247,24 @@ function App() { }, [metadata.metadata]); const checkForUpdates = async () => { try { - const response = await fetch("https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"); + const response = await fetch("https://api.github.com/repos/spotbye/SpotiFLAC/releases/latest"); const data = await response.json(); - const latestVersion = data.tag_name?.replace(/^v/, "") || ""; + const rawTag = data.tag_name || ""; + const latestVersion = rawTag.replace(/^v/, "") || ""; if (data.published_at) { setReleaseDate(data.published_at); } if (latestVersion && latestVersion > CURRENT_VERSION) { setHasUpdate(true); + setUpdateInfo({ + version: latestVersion, + changelog: extractMarkdownSection(data.body || "", "Changelog"), + url: `https://github.com/spotbye/SpotiFLAC/releases/tag/${rawTag}`, + }); + const dismissedVersion = localStorage.getItem("spotiflac_update_dismissed_version"); + if (dismissedVersion !== latestVersion) { + setShowUpdateDialog(true); + } } } catch (err) { @@ -363,6 +382,7 @@ function App() { name: track.name, artist: track.artists, image: track.images, + is_explicit: track.is_explicit, }; } else if ("album_info" in metadata.metadata) { @@ -373,6 +393,7 @@ function App() { name: album_info.name, artist: `${album_info.total_tracks.toLocaleString()} tracks`, image: album_info.images, + is_explicit: album_info.is_explicit, }; } else if ("playlist_info" in metadata.metadata) { @@ -546,6 +567,8 @@ function App() { return ; case "file-manager": return ; + case "lyrics-manager": + return ; default: return (<>
@@ -626,6 +649,43 @@ function App() { )} + + + + Update Available + + A new version{updateInfo ? ` (v${updateInfo.version})` : ""} is available. You're on v{CURRENT_VERSION}. + + + {updateInfo?.changelog ? (
+ +
) : (

No changelog provided for this release.

)} + + +
+ + +
+
+
+
+ diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx index 6c3d722..c2c8b23 100644 --- a/frontend/src/components/AlbumInfo.tsx +++ b/frontend/src/components/AlbumInfo.tsx @@ -12,7 +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 { buildClickableArtists, splitArtistNames, getClickableArtistKey } from "@/lib/artist-links"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; interface AlbumInfoProps { albumInfo: { @@ -21,6 +21,7 @@ interface AlbumInfoProps { images: string; release_date: string; total_tracks: number; + is_explicit?: boolean; artist_id?: string; artist_url?: string; }; @@ -206,18 +207,21 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT )}
-

Album

+

+ {albumInfo.is_explicit && (E)} + Album +

{albumInfo.name}

- {clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => ( - {onArtistClick && artist.external_urls ? ( onArtistClick({ + {clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => ( + {onArtistClick && artist.external_urls ? () : (artist.name)} {index < clickableAlbumArtists.length - 1 && artistSeparator} )) : albumInfo.artists} diff --git a/frontend/src/components/ApiStatusTab.tsx b/frontend/src/components/ApiStatusTab.tsx index e451746..93b0659 100644 --- a/frontend/src/components/ApiStatusTab.tsx +++ b/frontend/src/components/ApiStatusTab.tsx @@ -1,8 +1,9 @@ import { Button } from "@/components/ui/button"; -import { PlugZap, CheckCircle2, Loader2, Wrench } from "lucide-react"; +import { PlugZap, CheckCircle2, Loader2, Wrench, Server } from "lucide-react"; import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons"; import { useApiStatus } from "@/hooks/useApiStatus"; import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status"; +import { openExternal } from "@/lib/utils"; function renderStatusIndicator(status: "checking" | "online" | "offline" | "idle") { if (status === "online") { return ; @@ -31,14 +32,25 @@ export function ApiStatusTab() { const { sources, statuses, nextStatuses, checkingSources, checkAllCurrent, checkAllNext } = useApiStatus(); const isCheckingCurrent = sources.some((source) => checkingSources[source.id] === true); const isCheckingNext = SPOTIFLAC_NEXT_SOURCES.some((source) => nextStatuses[source.id] === "checking"); + const isChecking = isCheckingCurrent || isCheckingNext; + const checkAll = () => { + void checkAllCurrent(); + void checkAllNext(); + }; return (

SpotiFLAC

- +
+ + +
@@ -60,13 +72,7 @@ export function ApiStatusTab() {
-
-

SpotiFLAC Next

- -
+

SpotiFLAC Next

{SPOTIFLAC_NEXT_SOURCES.map((source) => { diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx index 8737cbb..2d25138 100644 --- a/frontend/src/components/ArtistInfo.tsx +++ b/frontend/src/components/ArtistInfo.tsx @@ -36,6 +36,7 @@ interface ArtistInfoProps { album_type: string; external_urls: string; total_tracks?: number; + is_explicit?: boolean; }>; trackList: TrackMetadata[]; searchQuery: string; @@ -475,7 +476,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
- {artistInfo.gallery!.map((imageUrl, index) => (
+ {artistInfo.gallery!.map((imageUrl, index) => (
{`${artistInfo.name}
@@ -537,7 +538,10 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
-

{album.name}

+

+ {album.is_explicit && (E)} + {album.name} +

{album.release_date?.split("-")[0]} {album.total_tracks && (<> diff --git a/frontend/src/components/AudioConverterPage.tsx b/frontend/src/components/AudioConverterPage.tsx index 8c2a4f2..17b5d0e 100644 --- a/frontend/src/components/AudioConverterPage.tsx +++ b/frontend/src/components/AudioConverterPage.tsx @@ -51,12 +51,12 @@ export function AudioConverterPage() { } return []; }); - const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => { + const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a" | "wav" | "aiff" | "opus">(() => { try { const saved = sessionStorage.getItem(STORAGE_KEY); if (saved) { const parsed = JSON.parse(saved); - if (parsed.outputFormat === "mp3" || parsed.outputFormat === "m4a") { + if (["mp3", "m4a", "wav", "aiff", "opus"].includes(parsed.outputFormat)) { return parsed.outputFormat; } } @@ -98,7 +98,7 @@ export function AudioConverterPage() { const [isFullscreen, setIsFullscreen] = useState(false); const saveState = useCallback((stateToSave: { files: AudioFile[]; - outputFormat: "mp3" | "m4a"; + outputFormat: "mp3" | "m4a" | "wav" | "aiff" | "opus"; bitrate: string; m4aCodec: "aac" | "alac"; }) => { @@ -116,7 +116,7 @@ export function AudioConverterPage() { if (files.length === 0) return; const allMP3 = files.every((f) => f.format === "mp3"); - if (allMP3 && outputFormat !== "m4a") { + if (allMP3 && outputFormat === "mp3") { setOutputFormat("m4a"); } const hasFlac = files.some((f) => f.format === "flac"); @@ -375,15 +375,24 @@ export function AudioConverterPage() {
{ - if (value && !isFormatDisabled) - setOutputFormat(value as "mp3" | "m4a"); - }} disabled={isFormatDisabled}> + if (value) + setOutputFormat(value as "mp3" | "m4a" | "wav" | "aiff" | "opus"); + }}> {!isFormatDisabled && ( MP3 )} - + M4A + + Opus + + + WAV + + + AIFF +
@@ -399,7 +408,7 @@ export function AudioConverterPage() {
)} - {!(outputFormat === "m4a" && m4aCodec === "alac") && (
+ {(outputFormat === "mp3" || outputFormat === "opus" || (outputFormat === "m4a" && m4aCodec === "aac")) && (
{ if (value) diff --git a/frontend/src/components/DownloadProgress.tsx b/frontend/src/components/DownloadProgress.tsx index d790e4a..f18fb65 100644 --- a/frontend/src/components/DownloadProgress.tsx +++ b/frontend/src/components/DownloadProgress.tsx @@ -1,6 +1,7 @@ import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; -import { StopCircle } from "lucide-react"; +import { StopCircle, Clock } from "lucide-react"; +import { useDownloadProgress } from "@/hooks/useDownloadProgress"; interface DownloadProgressProps { progress: number; remainingCount?: number; @@ -11,6 +12,9 @@ interface DownloadProgressProps { onStop: () => void; } export function DownloadProgress({ progress, remainingCount = 0, currentTrack, onStop }: DownloadProgressProps) { + const liveProgress = useDownloadProgress(); + const isRateLimited = Boolean(liveProgress.rate_limited) && (liveProgress.rate_limit_secs ?? 0) > 0; + const rateLimitSecs = liveProgress.rate_limit_secs ?? 0; const clampedProgress = Math.min(100, Math.max(0, progress)); const safeRemainingCount = Math.max(0, remainingCount); const remainingLabel = `${safeRemainingCount.toLocaleString()} ${safeRemainingCount === 1 ? "track" : "tracks"} left`; @@ -22,11 +26,14 @@ export function DownloadProgress({ progress, remainingCount = 0, currentTrack, o Stop
-

+ {isRateLimited ? (

+ + Rate limited, please wait. Retrying in {rateLimitSecs}s... +

) : (

{clampedProgress}% • {remainingLabel} -{" "} {currentTrack - ? `${currentTrack.name} - ${currentTrack.artists}` - : "Preparing download..."} -

+ ? `${currentTrack.name} - ${currentTrack.artists}` + : "Preparing download..."} +

)}
); } diff --git a/frontend/src/components/FetchHistory.tsx b/frontend/src/components/FetchHistory.tsx index 774b3a0..771e56a 100644 --- a/frontend/src/components/FetchHistory.tsx +++ b/frontend/src/components/FetchHistory.tsx @@ -6,6 +6,7 @@ export interface HistoryItem { name: string; artist: string; image: string; + is_explicit?: boolean; timestamp: number; } interface FetchHistoryProps { @@ -75,9 +76,12 @@ export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps)
)}
-

- {item.name} -

+
+ {item.is_explicit ? E : null} +

+ {item.name} +

+

{item.artist}

diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 9004e26..a22a570 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -11,9 +11,13 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) { return (
- SpotiFLAC window.location.reload()}/> -

window.location.reload()}> - SpotiFLAC + +

+

diff --git a/frontend/src/components/HistoryPage.tsx b/frontend/src/components/HistoryPage.tsx index 499cd4d..b0f99fa 100644 --- a/frontend/src/components/HistoryPage.tsx +++ b/frontend/src/components/HistoryPage.tsx @@ -75,6 +75,7 @@ interface FetchHistoryItem { info: string; image: string; data: string; + is_explicit?: boolean; timestamp: number; } interface HistoryPageProps { @@ -566,7 +567,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) { {item.type.slice(0, 2).toUpperCase()}
)}
- {item.name} + + {item.is_explicit && (E)} + {item.name} +
diff --git a/frontend/src/components/LyricsManagerPage.tsx b/frontend/src/components/LyricsManagerPage.tsx new file mode 100644 index 0000000..2592809 --- /dev/null +++ b/frontend/src/components/LyricsManagerPage.tsx @@ -0,0 +1,327 @@ +import { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Upload, X, FileText, Trash2, AlertCircle, Music, Clock, Download } from "lucide-react"; +import { Spinner } from "@/components/ui/spinner"; +import { ReadEmbeddedLyrics, SelectLyricsFiles, ExtractLyricsToLRC } from "../../wailsjs/go/main/App"; +import { toastWithSound as toast } from "@/lib/toast-with-sound"; +import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; +interface LyricsFile { + path: string; + name: string; + format: string; + lyrics: string; + source: string; + synced: boolean; + status: "loading" | "loaded" | "empty" | "error"; + error?: string; +} +const SUPPORTED_EXTENSIONS = [".lrc", ".txt", ".flac", ".mp3", ".m4a", ".aac", ".opus", ".ogg"]; +function getExtension(path: string): string { + const lower = path.toLowerCase(); + const dot = lower.lastIndexOf("."); + return dot >= 0 ? lower.slice(dot) : ""; +} +export function LyricsManagerPage() { + const [files, setFiles] = useState([]); + const [selectedPath, setSelectedPath] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [extracting, setExtracting] = useState(false); + useEffect(() => { + const checkFullscreen = () => { + setIsFullscreen(window.innerHeight >= window.screen.height * 0.9); + }; + checkFullscreen(); + window.addEventListener("resize", checkFullscreen); + window.addEventListener("focus", checkFullscreen); + return () => { + window.removeEventListener("resize", checkFullscreen); + window.removeEventListener("focus", checkFullscreen); + }; + }, []); + const addFiles = useCallback(async (paths: string[]) => { + const validPaths = paths.filter((path) => SUPPORTED_EXTENSIONS.includes(getExtension(path))); + if (validPaths.length === 0) { + if (paths.length > 0) { + toast.error("Unsupported files", { + description: "Only LRC and audio files (FLAC, MP3, M4A) are supported.", + }); + } + return; + } + const newPaths: string[] = []; + setFiles((prev) => { + const toAdd = validPaths.filter((path) => !prev.some((f) => f.path === path)); + newPaths.push(...toAdd); + const entries: LyricsFile[] = toAdd.map((path) => { + const name = path.split(/[/\\]/).pop() || path; + return { + path, + name, + format: getExtension(path).slice(1), + lyrics: "", + source: "", + synced: false, + status: "loading" as const, + }; + }); + if (entries.length === 0) { + return prev; + } + return [...prev, ...entries]; + }); + for (const path of newPaths) { + try { + const result = await ReadEmbeddedLyrics(path); + setFiles((prev) => prev.map((f) => { + if (f.path !== path) + return f; + if (result.error) { + return { ...f, status: "empty" as const, error: result.error }; + } + return { + ...f, + lyrics: result.lyrics, + source: result.source, + synced: result.synced, + status: "loaded" as const, + }; + })); + } + catch (err) { + setFiles((prev) => prev.map((f) => f.path === path + ? { ...f, status: "error" as const, error: err instanceof Error ? err.message : "Failed to read lyrics" } + : f)); + } + } + setSelectedPath((prev) => prev ?? newPaths[0] ?? null); + }, []); + const handleSelectFiles = async () => { + try { + const selected = await SelectLyricsFiles(); + if (selected && selected.length > 0) { + addFiles(selected); + } + } + catch (err) { + toast.error("File Selection Failed", { + description: err instanceof Error ? err.message : "Failed to select files", + }); + } + }; + const handleFileDrop = useCallback((_x: number, _y: number, paths: string[]) => { + setIsDragging(false); + if (paths.length === 0) + return; + addFiles(paths); + }, [addFiles]); + useEffect(() => { + OnFileDrop((x, y, paths) => { + handleFileDrop(x, y, paths); + }, true); + return () => { + OnFileDropOff(); + }; + }, [handleFileDrop]); + const removeFile = (path: string) => { + setFiles((prev) => { + const next = prev.filter((f) => f.path !== path); + setSelectedPath((current) => { + if (current !== path) + return current; + return next[0]?.path ?? null; + }); + return next; + }); + }; + const clearFiles = () => { + setFiles([]); + setSelectedPath(null); + }; + const selectedFile = files.find((f) => f.path === selectedPath) || null; + const extractFile = async (file: LyricsFile, overwrite: boolean) => { + const result = await ExtractLyricsToLRC(file.path, overwrite); + if (result.success) { + return { ok: true as const, output: result.output_path }; + } + if (result.already_exists) { + return { ok: false as const, alreadyExists: true, output: result.output_path }; + } + return { ok: false as const, error: result.error || "Failed to extract lyrics" }; + }; + const handleExtractSelected = async () => { + if (!selectedFile || selectedFile.status !== "loaded") + return; + setExtracting(true); + try { + const result = await extractFile(selectedFile, false); + if (result.ok) { + toast.success("Lyrics extracted", { description: result.output }); + } + else if (result.alreadyExists) { + toast.info("LRC already exists", { + description: "A .lrc file with the same name already exists next to this file.", + }); + } + else { + toast.error("Extract failed", { description: result.error }); + } + } + catch (err) { + toast.error("Extract failed", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } + finally { + setExtracting(false); + } + }; + const handleExtractAll = async () => { + const extractable = files.filter((f) => f.status === "loaded"); + if (extractable.length === 0) { + toast.error("Nothing to extract", { + description: "No files with embedded lyrics are loaded.", + }); + return; + } + setExtracting(true); + let success = 0; + let skipped = 0; + let failed = 0; + for (const file of extractable) { + try { + const result = await extractFile(file, false); + if (result.ok) + success++; + else if (result.alreadyExists) + skipped++; + else + failed++; + } + catch { + failed++; + } + } + setExtracting(false); + if (success > 0) { + toast.success("Lyrics extracted", { + description: `${success} file(s) extracted${skipped > 0 ? `, ${skipped} skipped` : ""}${failed > 0 ? `, ${failed} failed` : ""}`, + }); + } + else if (skipped > 0 && failed === 0) { + toast.info("Already extracted", { + description: `${skipped} .lrc file(s) already exist.`, + }); + } + else { + toast.error("Extract failed", { + description: `${failed} file(s) failed to extract.`, + }); + } + }; + const embeddedLoadedCount = files.filter((f) => f.status === "loaded" && f.source === "embedded").length; + return (
+
+

Lyrics Manager

+ {files.length > 0 && (
+ + {embeddedLoadedCount > 0 && ()} + +
)} +
+ +
{ + e.preventDefault(); + setIsDragging(true); + }} onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + }} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}> + {files.length === 0 ? (<> +
+ +
+

+ {isDragging + ? "Drop your files here" + : "Drag and drop LRC or audio files here, or click the button below to select"} +

+ +

+ Reads embedded lyrics from FLAC, MP3, M4A, Opus or plain LRC files +

+ ) : (
+ +
+ {files.map((file) => { + const isActive = file.path === selectedPath; + return (); + })} +
+ +
+ {!selectedFile ? (
+ Select a file to view its lyrics +
) : selectedFile.status === "loading" ? (
+ + Reading lyrics... +
) : selectedFile.status === "error" || selectedFile.status === "empty" ? (
+ +

{selectedFile.name}

+

{selectedFile.error || "No lyrics found"}

+
) : (<> +
+
+

{selectedFile.name}

+ + {selectedFile.source === "lrc" ? (<> LRC) : (<> Embedded)} + + + + {selectedFile.synced ? "Synced" : "Plain"} + +
+
+ {selectedFile.source === "embedded" && ()} +
+
+
+
{selectedFile.lyrics}
+
+ )} +
+
)} +
+
); +} diff --git a/frontend/src/components/MarkdownLite.tsx b/frontend/src/components/MarkdownLite.tsx new file mode 100644 index 0000000..eccd297 --- /dev/null +++ b/frontend/src/components/MarkdownLite.tsx @@ -0,0 +1,99 @@ +import { Fragment, type ReactNode } from "react"; +import { openExternal } from "@/lib/utils"; +export function extractMarkdownSection(body: string, heading: string): string { + const text = (body || "").replace(/\r\n/g, "\n"); + const lines = text.split("\n"); + const target = heading.trim().toLowerCase(); + let start = -1; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^#{1,6}\s+(.*)$/); + if (m && m[1].trim().toLowerCase() === target) { + start = i + 1; + break; + } + } + if (start === -1) { + return text.trim(); + } + const collected: string[] = []; + for (let i = start; i < lines.length; i++) { + if (/^#{1,6}\s+/.test(lines[i])) { + break; + } + collected.push(lines[i]); + } + return collected.join("\n").trim(); +} +function renderInline(text: string, keyPrefix: string): ReactNode[] { + const nodes: ReactNode[] = []; + const pattern = /\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + let i = 0; + while ((match = pattern.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push({text.slice(lastIndex, match.index)}); + } + if (match[1] !== undefined && match[2] !== undefined) { + const label = match[1]; + const url = match[2]; + nodes.push(); + } + else if (match[3] !== undefined) { + nodes.push({match[3]}); + } + else if (match[4] !== undefined) { + nodes.push({match[4]}); + } + else if (match[5] !== undefined) { + nodes.push({match[5]}); + } + lastIndex = pattern.lastIndex; + i++; + } + if (lastIndex < text.length) { + nodes.push({text.slice(lastIndex)}); + } + return nodes; +} +export function MarkdownLite({ content }: { + content: string; +}) { + const lines = (content || "").replace(/\r\n/g, "\n").split("\n"); + const blocks: ReactNode[] = []; + let listItems: string[] = []; + let key = 0; + const flushList = () => { + if (listItems.length === 0) + return; + const items = listItems; + listItems = []; + blocks.push(
    + {items.map((item, idx) => (
  • {renderInline(item, `li-${key}-${idx}`)}
  • ))} +
); + }; + for (const raw of lines) { + const line = raw.trimEnd(); + const bullet = line.match(/^\s*[-*]\s+(.*)$/); + if (bullet) { + listItems.push(bullet[1]); + continue; + } + flushList(); + const heading = line.match(/^(#{1,6})\s+(.*)$/); + if (heading) { + blocks.push(

+ {renderInline(heading[2], `h-${key}`)} +

); + continue; + } + if (line.trim() === "") { + continue; + } + blocks.push(

{renderInline(line, `p-${key}`)}

); + } + flushList(); + return
{blocks}
; +} diff --git a/frontend/src/components/OtherProjects.tsx b/frontend/src/components/OtherProjects.tsx index ba75fb0..350489a 100644 --- a/frontend/src/components/OtherProjects.tsx +++ b/frontend/src/components/OtherProjects.tsx @@ -201,9 +201,9 @@ export function OtherProjects() { {repoStats["SpotiFLAC-Next"] && ( {repoStats["SpotiFLAC-Next"].languages?.length > 0 && (
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => ( + backgroundColor: getLangColor(lang) + "20", + color: getLangColor(lang), + }}> {lang} ))}
)} @@ -255,9 +255,9 @@ export function OtherProjects() { {repoStats["Twitter-X-Media-Batch-Downloader"] && (
{repoStats["Twitter-X-Media-Batch-Downloader"].languages?.map((lang: string) => ( + backgroundColor: getLangColor(lang) + "20", + color: getLangColor(lang), + }}> {lang} ))}
@@ -273,19 +273,19 @@ export function OtherProjects() { {" "} {formatTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"] - .createdAt)} + .createdAt)}
TOTAL:{" "} {formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"] - .totalDownloads)} + .totalDownloads)} LATEST:{" "} {formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"] - .latestDownloads)} + .latestDownloads)}
)} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index 8ee2e50..9ed9fce 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -604,14 +604,22 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist {!searchMode && (<> {showRegionSelector && ( setTempSettings((prev) => ({ + ...prev, + linkResolver: value, + }))}> + + + + + + + + Songlink + + + + + + Songstats + + + + +
+
+ +
+ setTempSettings((prev) => ({ + ...prev, + allowResolverFallback: checked, + }))}/> + +
+ +
+
+ + + + + + +

1 track / 30s

+
+
@@ -391,12 +466,12 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin Auto - {hasCustomTidalInstanceConfigured && ( - - - Tidal - - )} + + + + Tidal + + @@ -421,8 +496,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin - {hasCustomTidalInstanceConfigured && (<> - + @@ -504,7 +578,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin - )} @@ -553,15 +626,23 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin )} - {effectiveDownloader === "amazon" && (
- 16-bit - 24-bit/44.1kHz - 192kHz -
)} + {effectiveDownloader === "amazon" && ()}
{((effectiveDownloader === "tidal" && tempSettings.tidalQuality === "HI_RES_LOSSLESS") || (effectiveDownloader === "qobuz" && tempSettings.qobuzQuality === "27") || + (effectiveDownloader === "amazon" && + tempSettings.amazonQuality === "24") || (effectiveDownloader === "auto" && tempSettings.autoQuality === "24")) && (
setTempSettings((prev) => ({ @@ -576,42 +657,34 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
+
+ +
+
- -
- + +
+ + {tempSettings.customTidalApi && ( + {tempSettings.customTidalApi} + )}
-
- setTempSettings((prev) => ({ - ...prev, - allowResolverFallback: checked, - }))}/> - +
+ +
+ + {tempSettings.customQobuzApi && ( + {tempSettings.customQobuzApi} + )} +
)} @@ -936,7 +1009,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
Tidal Source
@@ -982,6 +1055,58 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
+ + + +
+ Qobuz Source + +
+ +
+
+
+ +
+ { + const nextValue = e.target.value.replace(/\/+$/g, ""); + setCustomQobuzApiStatus("idle"); + void persistCustomQobuzApi(nextValue); + }} placeholder="https://your-qobuz-dl.example"/> + + {tempSettings.customQobuzApi && ()} +
+
+ {customQobuzApiStatus !== "idle" && (

+ {customQobuzApiStatus === "online" + ? "Custom Qobuz-DL instance is online." + : customQobuzApiStatus === "offline" + ? "Custom Qobuz-DL instance is offline or returned no download URL." + : "Checking custom Qobuz-DL instance..."} +

)} +
+ + + +
+
+ diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1554a08..2fb1f29 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity" import { TerminalIcon } from "@/components/ui/terminal"; import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music"; import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen"; +import { FileTextIcon, type FileTextIconHandle } from "@/components/ui/file-text"; import { BugReportIcon } from "@/components/ui/bug-report-icon"; import { CoffeeIcon } from "@/components/ui/coffee"; import { BlocksIcon } from "@/components/ui/blocks-icon"; @@ -17,7 +18,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { openExternal } from "@/lib/utils"; -export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "projects" | "support" | "history"; +export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "lyrics-manager" | "projects" | "support" | "history"; interface SidebarProps { currentPage: PageType; onPageChange: (page: PageType) => void; @@ -33,6 +34,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { const resamplerIconRef = useRef(null); const converterIconRef = useRef(null); const fileManagerIconRef = useRef(null); + const lyricsManagerIconRef = useRef(null); const handleIssuesDialogChange = (open: boolean) => { setIsIssuesDialogOpen(open); if (!open) { @@ -99,7 +101,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - @@ -125,6 +127,10 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { File Manager + onPageChange("lyrics-manager")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(lyricsManagerIconRef)}> + + Lyrics Manager + diff --git a/frontend/src/components/SupportPage.tsx b/frontend/src/components/SupportPage.tsx index 3811e9e..ad9ee85 100644 --- a/frontend/src/components/SupportPage.tsx +++ b/frontend/src/components/SupportPage.tsx @@ -7,7 +7,6 @@ import KofiSvg from "@/assets/kofi_symbol.svg"; import PatreonLogo from "@/assets/patreon.svg"; import PatreonSymbol from "@/assets/patreon_symbol.svg"; import UsdtBarcode from "@/assets/usdt.jpg"; - export function SupportPage() { const [copiedUsdt, setCopiedUsdt] = useState(false); const [copiedEmail, setCopiedEmail] = useState(false); diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx index 4ea8e7a..cc7b6ea 100644 --- a/frontend/src/components/TrackInfo.tsx +++ b/frontend/src/components/TrackInfo.tsx @@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/toolti import type { TrackMetadata, TrackAvailability } from "@/types/api"; import { usePreview } from "@/hooks/usePreview"; import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks"; -import { buildClickableArtists } from "@/lib/artist-links"; +import { buildClickableArtists, getClickableArtistKey } from "@/lib/artist-links"; interface TrackInfoProps { track: TrackMetadata & { album_name: string; @@ -83,14 +83,14 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded {isSkipped ? () : isDownloaded ? () : isFailed ? () : null}

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

@@ -99,13 +99,13 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded

Album

-

{hasAlbumClick ? ( onAlbumClick?.({ +

{hasAlbumClick ? () : (track.album_name)}

{track.plays && (

Total Plays

diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx index c161814..82ddac7 100644 --- a/frontend/src/components/TrackList.tsx +++ b/frontend/src/components/TrackList.tsx @@ -7,7 +7,7 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi import type { TrackMetadata, TrackAvailability } from "@/types/api"; import { usePreview } from "@/hooks/usePreview"; import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks"; -import { buildClickableArtists } from "@/lib/artist-links"; +import { buildClickableArtists, getClickableArtistKey } from "@/lib/artist-links"; interface TrackListProps { tracks: TrackMetadata[]; searchQuery: string; @@ -55,6 +55,7 @@ interface TrackListProps { } export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) { const { playPreview, loadingPreview, playingTrack } = usePreview(); + const getTrackKey = (track: TrackMetadata) => track.spotify_id || track.external_urls || `${track.name}-${track.album_name}-${track.disc_number ?? 1}-${track.track_number}`; let filteredTracks = tracks.filter((track) => { if (!searchQuery) return true; @@ -219,7 +220,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa - {paginatedTracks.map((track, index) => ( + {paginatedTracks.map((track, index) => ( {showCheckboxes && ( {track.spotify_id && ( onToggleTrack(track.spotify_id!)}/>)} )} @@ -242,9 +243,9 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa {track.images && ({track.name})}
- {onTrackClick ? ( onTrackClick(track)}> + {onTrackClick ? () : ({track.name})} {track.is_explicit && (E)} {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} @@ -255,14 +256,14 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa if (clickableArtists.length === 0) { return track.artists; } - return clickableArtists.map((artist, i) => ( - {onArtistClick ? ( onArtistClick({ + return clickableArtists.map((artist, i) => ( + {onArtistClick ? () : (artist.name)} {i < clickableArtists.length - 1 && ", "} )); })()} @@ -271,13 +272,13 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
{!hideAlbumColumn && ( - {onAlbumClick && track.album_id && track.album_url ? ( onAlbumClick({ + {onAlbumClick && track.album_id && track.album_url ? () : (track.album_name)} )} {formatDuration(track.duration_ms)} diff --git a/frontend/src/components/ui/bug-report-icon.tsx b/frontend/src/components/ui/bug-report-icon.tsx index 463f9ff..5efecb1 100644 --- a/frontend/src/components/ui/bug-report-icon.tsx +++ b/frontend/src/components/ui/bug-report-icon.tsx @@ -1,19 +1,14 @@ "use client"; - import type { Transition, Variants } from "motion/react"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useState, type HTMLAttributes } from "react"; import { cn } from "@/lib/utils"; - type ReportIconMode = "bug" | "bulb"; - interface BugReportIconProps extends HTMLAttributes { size?: number; loop?: boolean; } - const LOOP_INTERVAL_MS = 2200; - const GROUP_VARIANTS: Variants = { hidden: { opacity: 0, @@ -33,7 +28,6 @@ const GROUP_VARIANTS: Variants = { }, }, }; - const DRAW_VARIANTS: Variants = { hidden: { pathLength: 0, @@ -48,7 +42,6 @@ const DRAW_VARIANTS: Variants = { opacity: 0, }, }; - function createDrawTransition(delay = 0, duration = 0.36): Transition { return { duration, @@ -57,7 +50,6 @@ function createDrawTransition(delay = 0, duration = 0.36): Transition { opacity: { delay }, }; } - function BugPaths() { return (<> @@ -73,7 +65,6 @@ function BugPaths() { ); } - function BulbPaths() { return (<> @@ -81,13 +72,13 @@ function BulbPaths() { ); } - -function ReportIconGroup({ mode }: { mode: ReportIconMode }) { +function ReportIconGroup({ mode }: { + mode: ReportIconMode; +}) { return ( - {mode === "bug" ? : } + {mode === "bug" ? : } ); } - function StaticBugIcon() { return ( @@ -103,30 +94,24 @@ function StaticBugIcon() { ); } - function BugReportIcon({ className, size = 28, loop = false, ...props }: BugReportIconProps) { const [mode, setMode] = useState("bug"); - useEffect(() => { if (!loop) { setMode("bug"); return; } - const intervalId = window.setInterval(() => { setMode((currentMode) => currentMode === "bug" ? "bulb" : "bug"); }, LOOP_INTERVAL_MS); - return () => window.clearInterval(intervalId); }, [loop]); - return (
{loop ? ( - ) : ()} + ) : ()}
); } - export { BugReportIcon }; diff --git a/frontend/src/components/ui/file-text.tsx b/frontend/src/components/ui/file-text.tsx new file mode 100644 index 0000000..14aaad1 --- /dev/null +++ b/frontend/src/components/ui/file-text.tsx @@ -0,0 +1,65 @@ +'use client'; +import type { Variants } from 'motion/react'; +import type { HTMLAttributes } from 'react'; +import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; +import { motion, useAnimation } from 'motion/react'; +import { cn } from '@/lib/utils'; +export interface FileTextIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} +interface FileTextIconProps extends HTMLAttributes { + size?: number; +} +const PATH_VARIANTS: Variants = { + normal: { + pathLength: 1, + opacity: 1, + }, + animate: { + pathLength: [0, 1], + opacity: [0, 1], + transition: { + duration: 0.6, + ease: 'easeInOut', + }, + }, +}; +const FileTextIcon = forwardRef(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + useImperativeHandle(ref, () => { + isControlledRef.current = true; + return { + startAnimation: () => controls.start('animate'), + stopAnimation: () => controls.start('normal'), + }; + }); + const handleMouseEnter = useCallback((e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start('animate'); + } + else { + onMouseEnter?.(e); + } + }, [controls, onMouseEnter]); + const handleMouseLeave = useCallback((e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start('normal'); + } + else { + onMouseLeave?.(e); + } + }, [controls, onMouseLeave]); + return (
+ + + + + + + +
); +}); +FileTextIcon.displayName = 'FileTextIcon'; +export { FileTextIcon }; diff --git a/frontend/src/components/ui/tool-case.tsx b/frontend/src/components/ui/tool-case.tsx index 9b5f79b..d844b66 100644 --- a/frontend/src/components/ui/tool-case.tsx +++ b/frontend/src/components/ui/tool-case.tsx @@ -4,16 +4,13 @@ import type { HTMLAttributes } from 'react'; import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; import { motion, useAnimation } from 'motion/react'; import { cn } from '@/lib/utils'; - export interface ToolCaseIconHandle { startAnimation: () => void; stopAnimation: () => void; } - interface ToolCaseIconProps extends HTMLAttributes { size?: number; } - const DRAW_VARIANTS: Variants = { normal: { pathLength: 1, @@ -28,7 +25,6 @@ const DRAW_VARIANTS: Variants = { }, }, }; - const HANDLE_VARIANTS: Variants = { normal: { scaleX: 1, @@ -43,11 +39,9 @@ const HANDLE_VARIANTS: Variants = { }, }, }; - const ToolCaseIcon = forwardRef(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); - useImperativeHandle(ref, () => { isControlledRef.current = true; return { @@ -55,7 +49,6 @@ const ToolCaseIcon = forwardRef(({ onMous stopAnimation: () => controls.start('normal'), }; }); - const handleMouseEnter = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { controls.start('animate'); @@ -64,7 +57,6 @@ const ToolCaseIcon = forwardRef(({ onMous onMouseEnter?.(e); } }, [controls, onMouseEnter]); - const handleMouseLeave = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { controls.start('normal'); @@ -73,7 +65,6 @@ const ToolCaseIcon = forwardRef(({ onMous onMouseLeave?.(e); } }, [controls, onMouseLeave]); - return (
@@ -83,7 +74,5 @@ const ToolCaseIcon = forwardRef(({ onMous
); }); - ToolCaseIcon.displayName = 'ToolCaseIcon'; - export { ToolCaseIcon }; diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index 09c8fd1..2fe40f7 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -1,6 +1,6 @@ import { useState, useRef } from "react"; import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api"; -import { getSettings, hasConfiguredCustomTidalApi, parseTemplate, sanitizeAutoOrder, type TemplateData } from "@/lib/settings"; +import { getSettings, parseTemplate, sanitizeAutoOrder, type TemplateData } from "@/lib/settings"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils"; import { logger } from "@/lib/logger"; @@ -86,13 +86,15 @@ export function useDownload(region: string) { setDownloadRemainingCount(Math.max(0, safeTotalCount - safeCompletedCount)); }; const downloadWithAutoFallback = async (id: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => { - const allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi); - const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader; + const service = settings.downloader; const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined; const os = settings.operatingSystem; - const customTidalApi = allowTidal && typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://") + const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://") ? settings.customTidalApi.trim().replace(/\/+$/g, "") : undefined; + const customQobuzApi = typeof settings.customQobuzApi === "string" && settings.customQobuzApi.trim().startsWith("https://") + ? settings.customQobuzApi.trim().replace(/\/+$/g, "") + : undefined; let outputDir = settings.downloadPath; let useAlbumTrackNumber = false; const placeholder = "__SLASH_PLACEHOLDER__"; @@ -194,7 +196,303 @@ export function useDownload(region: string) { itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || ""); } if (service === "auto") { - const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-"); + const order = sanitizeAutoOrder(settings.autoOrder).split("-"); + let streamingURLs: any = null; + if (spotifyId && shouldFetchStreamingURLs(order)) { + try { + const { GetStreamingURLs } = await import("../../wailsjs/go/main/App"); + const urlsJson = await GetStreamingURLs(spotifyId, region); + streamingURLs = JSON.parse(urlsJson); + } + catch (err) { + console.error("Failed to get streaming URLs:", err); + } + } + const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; + let lastResponse: any = { success: false, error: "No matching services found" }; + const fallbackErrors: string[] = []; + const tidalQuality = getTidalAudioFormat(settings, "auto"); + const is24Bit = (settings.autoQuality || "24") === "24"; + const qobuzQuality = is24Bit ? "27" : "6"; + for (const s of order) { + if (s === "tidal" && streamingURLs?.tidal_url) { + try { + logger.debug(`trying Tidal for: ${trackName} - ${artistName}`); + const response = await downloadTrack({ + service: "tidal", + query, + track_name: trackName, + artist_name: displayArtist, + album_name: albumName, + album_artist: displayAlbumArtist, + release_date: finalReleaseDate || releaseDate, + cover_url: coverUrl, + output_dir: outputDir, + filename_format: settings.filenameTemplate, + track_number: settings.trackNumber, + position, + use_album_track_number: useAlbumTrackNumber, + spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, + embed_max_quality_cover: settings.embedMaxQualityCover, + service_url: streamingURLs?.tidal_url, + duration: durationSeconds, + item_id: itemID, + audio_format: tidalQuality, + tidal_api_url: customTidalApi, + spotify_track_number: spotifyTrackNumber, + 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, + use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, + }); + if (response.success) { + logger.success(`Tidal: ${trackName} - ${artistName}`); + return response; + } + const errMsg = response.error || response.message || "Failed"; + fallbackErrors.push(`[Tidal] ${errMsg}`); + lastResponse = response; + logger.warning(`Tidal failed, trying next...`); + } + catch (err) { + logger.error(`Tidal error: ${err}`); + fallbackErrors.push(`[Tidal] ${String(err)}`); + lastResponse = { success: false, error: String(err) }; + } + } + else if (s === "amazon" && streamingURLs?.amazon_url) { + try { + logger.debug(`trying amazon for: ${trackName} - ${artistName}`); + const response = await downloadTrack({ + service: "amazon", + query, + track_name: trackName, + artist_name: displayArtist, + album_name: albumName, + album_artist: displayAlbumArtist, + release_date: finalReleaseDate || releaseDate, + cover_url: coverUrl, + output_dir: outputDir, + filename_format: settings.filenameTemplate, + track_number: settings.trackNumber, + position, + use_album_track_number: useAlbumTrackNumber, + spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, + embed_max_quality_cover: settings.embedMaxQualityCover, + service_url: streamingURLs.amazon_url, + item_id: itemID, + audio_format: is24Bit ? "24" : "16", + spotify_track_number: spotifyTrackNumber, + spotify_disc_number: spotifyDiscNumber, + spotify_total_tracks: spotifyTotalTracks, + spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, + copyright: copyright, + publisher: publisher, + use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, + }); + if (response.success) { + logger.success(`amazon: ${trackName} - ${artistName}`); + return response; + } + const errMsg = response.error || response.message || "Failed"; + fallbackErrors.push(`[Amazon] ${errMsg}`); + lastResponse = response; + logger.warning(`amazon failed, trying next...`); + } + catch (err) { + logger.error(`amazon error: ${err}`); + fallbackErrors.push(`[Amazon] ${String(err)}`); + lastResponse = { success: false, error: String(err) }; + } + } + else if (s === "qobuz") { + try { + logger.debug(`trying qobuz for: ${trackName} - ${artistName}`); + const response = await downloadTrack({ + service: "qobuz", + query, + track_name: trackName, + artist_name: displayArtist, + album_name: albumName, + album_artist: displayAlbumArtist, + release_date: finalReleaseDate || releaseDate, + cover_url: coverUrl, + output_dir: outputDir, + filename_format: settings.filenameTemplate, + track_number: settings.trackNumber, + position: trackNumberForTemplate, + use_album_track_number: useAlbumTrackNumber, + spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, + embed_max_quality_cover: settings.embedMaxQualityCover, + item_id: itemID, + audio_format: qobuzQuality, + qobuz_api_url: customQobuzApi, + spotify_track_number: spotifyTrackNumber, + spotify_disc_number: spotifyDiscNumber, + spotify_total_tracks: spotifyTotalTracks, + spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, + copyright: copyright, + publisher: publisher, + use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, + }); + if (response.success) { + logger.success(`qobuz: ${trackName} - ${artistName}`); + return response; + } + const errMsg = response.error || response.message || "Failed"; + fallbackErrors.push(`[Qobuz] ${errMsg}`); + lastResponse = response; + logger.warning(`qobuz failed, trying next...`); + } + catch (err) { + logger.error(`qobuz error: ${err}`); + fallbackErrors.push(`[Qobuz] ${String(err)}`); + lastResponse = { success: false, error: String(err) }; + } + } + } + if (itemID) { + const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); + const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed"); + await MarkDownloadItemFailed(itemID, finalError); + } + return lastResponse; + } + const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined; + let audioFormat: string | undefined; + if (service === "tidal") { + audioFormat = getTidalAudioFormat(settings, "single"); + } + else if (service === "qobuz") { + audioFormat = settings.qobuzQuality || "6"; + } + else if (service === "amazon") { + audioFormat = settings.amazonQuality || "16"; + } + else if (service === "deezer") { + audioFormat = "flac"; + } + logger.debug(`trying ${service} for: ${trackName} - ${artistName}`); + const singleServiceResponse = await downloadTrack({ + service: service as "tidal" | "qobuz" | "amazon", + query, + track_name: trackName, + artist_name: displayArtist, + album_name: albumName, + album_artist: displayAlbumArtist, + release_date: finalReleaseDate || releaseDate, + cover_url: coverUrl, + output_dir: outputDir, + filename_format: settings.filenameTemplate, + track_number: settings.trackNumber, + position: trackNumberForTemplate, + use_album_track_number: useAlbumTrackNumber, + spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, + embed_max_quality_cover: settings.embedMaxQualityCover, + duration: durationSecondsForFallback, + item_id: itemID, + audio_format: audioFormat, + tidal_api_url: service === "tidal" ? customTidalApi : undefined, + qobuz_api_url: service === "qobuz" ? customQobuzApi : undefined, + spotify_track_number: spotifyTrackNumber, + 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, + use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, + }); + if (!singleServiceResponse.success && itemID) { + const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); + await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed"); + } + return singleServiceResponse; + }; + const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => { + const service = settings.downloader; + const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; + const os = settings.operatingSystem; + const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://") + ? settings.customTidalApi.trim().replace(/\/+$/g, "") + : undefined; + const customQobuzApi = typeof settings.customQobuzApi === "string" && settings.customQobuzApi.trim().startsWith("https://") + ? settings.customQobuzApi.trim().replace(/\/+$/g, "") + : undefined; + let outputDir = settings.downloadPath; + let useAlbumTrackNumber = false; + const placeholder = "__SLASH_PLACEHOLDER__"; + let finalReleaseDate = releaseDate; + let finalTrackNumber = spotifyTrackNumber || 0; + if (spotifyId) { + try { + const trackURL = `https://open.spotify.com/track/${spotifyId}`; + const trackMetadata = await fetchSpotifyMetadata(trackURL, false, 0, 10); + if ("track" in trackMetadata && trackMetadata.track) { + if (trackMetadata.track.release_date) { + finalReleaseDate = trackMetadata.track.release_date; + } + if (trackMetadata.track.track_number > 0) { + finalTrackNumber = trackMetadata.track.track_number; + } + } + } + catch (err) { + } + } + const yearValue = releaseYear || finalReleaseDate?.substring(0, 4); + const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== ""; + const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0); + 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: trackNumberForTemplate, + year: yearValue, + date: releaseDate, + playlist: folderName?.replace(/\//g, placeholder), + }; + const folderTemplate = settings.folderTemplate || ""; + const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}"); + if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumSubfolder)) { + outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); + } + if (settings.folderTemplate) { + const folderPath = parseTemplate(settings.folderTemplate, templateData); + if (folderPath) { + const parts = folderPath.split("/").filter(p => p.trim()); + for (const part of parts) { + const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " "); + outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os)); + } + } + } + if (service === "auto") { + const order = sanitizeAutoOrder(settings.autoOrder).split("-"); let streamingURLs: any = null; if (spotifyId && shouldFetchStreamingURLs(order)) { try { @@ -264,290 +562,6 @@ export function useDownload(region: string) { lastResponse = { success: false, error: String(err) }; } } - else if (s === "amazon" && streamingURLs?.amazon_url) { - try { - logger.debug(`trying amazon for: ${trackName} - ${artistName}`); - const response = await downloadTrack({ - service: "amazon", - query, - track_name: trackName, - artist_name: displayArtist, - album_name: albumName, - album_artist: displayAlbumArtist, - release_date: finalReleaseDate || releaseDate, - cover_url: coverUrl, - output_dir: outputDir, - filename_format: settings.filenameTemplate, - track_number: settings.trackNumber, - position, - use_album_track_number: useAlbumTrackNumber, - spotify_id: spotifyId, - embed_lyrics: settings.embedLyrics, - embed_max_quality_cover: settings.embedMaxQualityCover, - service_url: streamingURLs.amazon_url, - item_id: itemID, - spotify_track_number: spotifyTrackNumber, - spotify_disc_number: spotifyDiscNumber, - spotify_total_tracks: spotifyTotalTracks, - spotify_total_discs: spotifyTotalDiscs, - isrc: resolvedTemplateISRC || undefined, - copyright: copyright, - publisher: publisher, - use_single_genre: settings.useSingleGenre, - embed_genre: settings.embedGenre, - }); - if (response.success) { - logger.success(`amazon: ${trackName} - ${artistName}`); - return response; - } - const errMsg = response.error || response.message || "Failed"; - fallbackErrors.push(`[Amazon] ${errMsg}`); - lastResponse = response; - logger.warning(`amazon failed, trying next...`); - } - catch (err) { - logger.error(`amazon error: ${err}`); - fallbackErrors.push(`[Amazon] ${String(err)}`); - lastResponse = { success: false, error: String(err) }; - } - } - else if (s === "qobuz") { - try { - logger.debug(`trying qobuz for: ${trackName} - ${artistName}`); - const response = await downloadTrack({ - service: "qobuz", - query, - track_name: trackName, - artist_name: displayArtist, - album_name: albumName, - album_artist: displayAlbumArtist, - release_date: finalReleaseDate || releaseDate, - cover_url: coverUrl, - output_dir: outputDir, - filename_format: settings.filenameTemplate, - track_number: settings.trackNumber, - position: trackNumberForTemplate, - use_album_track_number: useAlbumTrackNumber, - spotify_id: spotifyId, - embed_lyrics: settings.embedLyrics, - embed_max_quality_cover: settings.embedMaxQualityCover, - item_id: itemID, - audio_format: qobuzQuality, - spotify_track_number: spotifyTrackNumber, - spotify_disc_number: spotifyDiscNumber, - spotify_total_tracks: spotifyTotalTracks, - spotify_total_discs: spotifyTotalDiscs, - isrc: resolvedTemplateISRC || undefined, - copyright: copyright, - publisher: publisher, - use_single_genre: settings.useSingleGenre, - embed_genre: settings.embedGenre, - }); - if (response.success) { - logger.success(`qobuz: ${trackName} - ${artistName}`); - return response; - } - const errMsg = response.error || response.message || "Failed"; - fallbackErrors.push(`[Qobuz] ${errMsg}`); - lastResponse = response; - logger.warning(`qobuz failed, trying next...`); - } - catch (err) { - logger.error(`qobuz error: ${err}`); - fallbackErrors.push(`[Qobuz] ${String(err)}`); - lastResponse = { success: false, error: String(err) }; - } - } - } - if (itemID) { - const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); - const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed"); - await MarkDownloadItemFailed(itemID, finalError); - } - return lastResponse; - } - const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined; - let audioFormat: string | undefined; - if (service === "tidal") { - audioFormat = getTidalAudioFormat(settings, "single"); - } - else if (service === "qobuz") { - audioFormat = settings.qobuzQuality || "6"; - } - else if (service === "deezer") { - audioFormat = "flac"; - } - logger.debug(`trying ${service} for: ${trackName} - ${artistName}`); - const singleServiceResponse = await downloadTrack({ - service: service as "tidal" | "qobuz" | "amazon", - query, - track_name: trackName, - artist_name: displayArtist, - album_name: albumName, - album_artist: displayAlbumArtist, - release_date: finalReleaseDate || releaseDate, - cover_url: coverUrl, - output_dir: outputDir, - filename_format: settings.filenameTemplate, - track_number: settings.trackNumber, - position: trackNumberForTemplate, - use_album_track_number: useAlbumTrackNumber, - spotify_id: spotifyId, - embed_lyrics: settings.embedLyrics, - embed_max_quality_cover: settings.embedMaxQualityCover, - duration: durationSecondsForFallback, - item_id: itemID, - audio_format: audioFormat, - tidal_api_url: service === "tidal" ? customTidalApi : undefined, - spotify_track_number: spotifyTrackNumber, - 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, - use_single_genre: settings.useSingleGenre, - embed_genre: settings.embedGenre, - }); - if (!singleServiceResponse.success && itemID) { - const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); - await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed"); - } - return singleServiceResponse; - }; - const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => { - const allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi); - const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader; - const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; - const os = settings.operatingSystem; - let outputDir = settings.downloadPath; - let useAlbumTrackNumber = false; - const placeholder = "__SLASH_PLACEHOLDER__"; - let finalReleaseDate = releaseDate; - let finalTrackNumber = spotifyTrackNumber || 0; - if (spotifyId) { - try { - const trackURL = `https://open.spotify.com/track/${spotifyId}`; - const trackMetadata = await fetchSpotifyMetadata(trackURL, false, 0, 10); - if ("track" in trackMetadata && trackMetadata.track) { - if (trackMetadata.track.release_date) { - finalReleaseDate = trackMetadata.track.release_date; - } - if (trackMetadata.track.track_number > 0) { - finalTrackNumber = trackMetadata.track.track_number; - } - } - } - catch (err) { - } - } - const yearValue = releaseYear || finalReleaseDate?.substring(0, 4); - const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== ""; - const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0); - 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: trackNumberForTemplate, - year: yearValue, - date: releaseDate, - playlist: folderName?.replace(/\//g, placeholder), - }; - const folderTemplate = settings.folderTemplate || ""; - const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}"); - if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumSubfolder)) { - outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); - } - if (settings.folderTemplate) { - const folderPath = parseTemplate(settings.folderTemplate, templateData); - if (folderPath) { - const parts = folderPath.split("/").filter(p => p.trim()); - for (const part of parts) { - const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " "); - outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os)); - } - } - } - if (service === "auto") { - const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-"); - let streamingURLs: any = null; - if (spotifyId && shouldFetchStreamingURLs(order)) { - try { - const { GetStreamingURLs } = await import("../../wailsjs/go/main/App"); - const urlsJson = await GetStreamingURLs(spotifyId, region); - streamingURLs = JSON.parse(urlsJson); - } - catch (err) { - console.error("Failed to get streaming URLs:", err); - } - } - const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; - let lastResponse: any = { success: false, error: "No matching services found" }; - const fallbackErrors: string[] = []; - const tidalQuality = getTidalAudioFormat(settings, "auto"); - const is24Bit = (settings.autoQuality || "24") === "24"; - const qobuzQuality = is24Bit ? "27" : "6"; - for (const s of order) { - if (s === "tidal" && streamingURLs?.tidal_url) { - try { - logger.debug(`trying Tidal for: ${trackName} - ${artistName}`); - const response = await downloadTrack({ - service: "tidal", - query, - track_name: trackName, - artist_name: displayArtist, - album_name: albumName, - album_artist: displayAlbumArtist, - release_date: finalReleaseDate || releaseDate, - cover_url: coverUrl, - output_dir: outputDir, - filename_format: settings.filenameTemplate, - track_number: settings.trackNumber, - position, - use_album_track_number: useAlbumTrackNumber, - spotify_id: spotifyId, - embed_lyrics: settings.embedLyrics, - embed_max_quality_cover: settings.embedMaxQualityCover, - service_url: streamingURLs?.tidal_url, - duration: durationSeconds, - item_id: itemID, - audio_format: tidalQuality, - spotify_track_number: spotifyTrackNumber, - 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, - use_single_genre: settings.useSingleGenre, - embed_genre: settings.embedGenre, - }); - if (response.success) { - logger.success(`Tidal: ${trackName} - ${artistName}`); - return response; - } - const errMsg = response.error || response.message || "Failed"; - fallbackErrors.push(`[Tidal] ${errMsg}`); - lastResponse = response; - logger.warning(`Tidal failed, trying next...`); - } - catch (err) { - logger.error(`Tidal error: ${err}`); - fallbackErrors.push(`[Tidal] ${String(err)}`); - lastResponse = { success: false, error: String(err) }; - } - } else if (s === "amazon" && streamingURLs?.amazon_url) { try { logger.debug(`trying amazon for: ${trackName} - ${artistName}`); @@ -619,6 +633,7 @@ export function useDownload(region: string) { duration: durationSeconds, item_id: itemID, audio_format: qobuzQuality, + qobuz_api_url: customQobuzApi, spotify_track_number: spotifyTrackNumber, spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, @@ -661,6 +676,9 @@ export function useDownload(region: string) { else if (service === "qobuz") { audioFormat = settings.qobuzQuality || "6"; } + else if (service === "amazon") { + audioFormat = settings.amazonQuality || "16"; + } const singleServiceResponse = await downloadTrack({ service: service as "tidal" | "qobuz" | "amazon", query, @@ -681,6 +699,8 @@ export function useDownload(region: string) { duration: durationSecondsForFallback, item_id: itemID, audio_format: audioFormat, + tidal_api_url: service === "tidal" ? customTidalApi : undefined, + qobuz_api_url: service === "qobuz" ? customQobuzApi : undefined, spotify_track_number: spotifyTrackNumber, spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, @@ -834,6 +854,10 @@ export function useDownload(region: string) { try { const releaseYear = track.release_date?.substring(0, 4); const response = await downloadWithItemID(settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher); + if (response.cancelled || shouldStopDownloadRef.current) { + toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`); + break; + } if (response.success) { if (response.already_exists) { skippedCount++; @@ -1007,6 +1031,10 @@ export function useDownload(region: string) { try { const releaseYear = track.release_date?.substring(0, 4); const response = await downloadWithItemID(settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher); + if (response.cancelled || shouldStopDownloadRef.current) { + toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`); + break; + } if (response.success) { if (response.already_exists) { skippedCount++; @@ -1085,6 +1113,15 @@ export function useDownload(region: string) { const handleStopDownload = () => { logger.info("download stopped by user"); shouldStopDownloadRef.current = true; + void (async () => { + try { + const { ForceStopDownloads } = await import("../../wailsjs/go/main/App"); + await ForceStopDownloads(); + } + catch (err) { + console.error("Failed to force stop downloads:", err); + } + })(); toast.info("Stopping download..."); }; const resetDownloadedTracks = () => { diff --git a/frontend/src/hooks/useDownloadProgress.ts b/frontend/src/hooks/useDownloadProgress.ts index ee7342e..2b49c9b 100644 --- a/frontend/src/hooks/useDownloadProgress.ts +++ b/frontend/src/hooks/useDownloadProgress.ts @@ -4,12 +4,16 @@ export interface DownloadProgressInfo { is_downloading: boolean; mb_downloaded: number; speed_mbps: number; + rate_limited?: boolean; + rate_limit_secs?: number; } export function useDownloadProgress() { const [progress, setProgress] = useState({ is_downloading: false, mb_downloaded: 0, speed_mbps: 0, + rate_limited: false, + rate_limit_secs: 0, }); const intervalRef = useRef(null); useEffect(() => { diff --git a/frontend/src/hooks/useMetadata.ts b/frontend/src/hooks/useMetadata.ts index 18e57ef..8de6a4a 100644 --- a/frontend/src/hooks/useMetadata.ts +++ b/frontend/src/hooks/useMetadata.ts @@ -159,6 +159,7 @@ export function useMetadata() { info: info, image: image, data: jsonStr, + is_explicit: ("track" in data && Boolean(data.track.is_explicit)) || ("album_info" in data && Boolean(data.album_info.is_explicit)), timestamp: Math.floor(Date.now() / 1000) }); } diff --git a/frontend/src/index.css b/frontend/src/index.css index dbb2340..1fce765 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -96,6 +96,21 @@ } } +@media (prefers-reduced-motion: reduce) { + html:focus-within { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} + @theme inline { --font-mono: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } diff --git a/frontend/src/lib/api-status.ts b/frontend/src/lib/api-status.ts index 2033802..46b0740 100644 --- a/frontend/src/lib/api-status.ts +++ b/frontend/src/lib/api-status.ts @@ -1,88 +1,60 @@ -import { CheckAPIStatus, CheckCustomTidalAPI } from "../../wailsjs/go/main/App"; import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout"; -import { getSettings, hasConfiguredCustomTidalApi } from "@/lib/settings"; - export type ApiCheckStatus = "checking" | "online" | "offline" | "idle"; - export interface ApiSource { id: string; type: string; name: string; url: string; } - interface SpotiFLACNextSource { id: string; name: string; statusKey?: string; statusPrefix?: string; } - type SpotiFLACNextStatusResponse = Partial>; -type ApiStatusTargetReport = { - target?: string; - label?: string; - online?: boolean; - message?: string; -}; -type ApiStatusReport = { - type?: string; - online?: boolean; - require_all?: boolean; - details?: ApiStatusTargetReport[]; -}; - export const API_SOURCES: ApiSource[] = [ { id: "tidal", type: "tidal", name: "Tidal", url: "" }, { id: "qobuz", type: "qobuz", name: "Qobuz", url: "" }, { id: "amazon", type: "amazon", name: "Amazon Music", url: "" }, ]; - export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [ - { id: "tidal", name: "Tidal", statusKey: "tidal" }, + { id: "tidal", name: "Tidal", statusPrefix: "tidal_" }, { id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" }, { id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" }, { id: "deezer", name: "Deezer", statusPrefix: "deezer_" }, { id: "apple", name: "Apple Music", statusKey: "apple" }, ]; - const SPOTIFLAC_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw"; -const SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY = "amazon_a"; +const SPOTIFLAC_CURRENT_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/7e392bc94ec2faaf74ef7d80025636eb/raw"; const SPOTIFLAC_STATUS_MAX_ATTEMPTS = 3; const SPOTIFLAC_STATUS_RETRY_DELAY_MS = 1200; -const CheckAPIStatusReport = (apiType: string, apiURL: string): Promise => (window as any)["go"]["main"]["App"]["CheckAPIStatusReport"](apiType, apiURL); const LogStatusConsole = (level: string, message: string): Promise => (window as any)["go"]["main"]["App"]["LogStatusConsole"](level, message); - type ApiStatusState = { checkingSources: Record; statuses: Record; nextStatuses: Record; }; - let apiStatusState: ApiStatusState = { checkingSources: {}, statuses: {}, nextStatuses: {}, }; - let activeCheckCurrentOnly: Promise | null = null; let activeCheckNextOnly: Promise | null = null; let activeStatusPayloadFetch: Promise | null = null; - +let activeCurrentStatusPayloadFetch: Promise | null = null; const activeSourceChecks = new Map>(); const listeners = new Set<() => void>(); - function emitApiStatusChange() { for (const listener of listeners) { listener(); } } - function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) { apiStatusState = updater(apiStatusState); emitApiStatusChange(); } - function delay(ms: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, ms)); } @@ -94,52 +66,12 @@ function sendStatusConsole(level: "info" | "warning" | "error", message: string) return; } } -function logStatusInfo(message: string): void { - sendStatusConsole("info", message); -} -function logStatusWarning(message: string): void { - sendStatusConsole("warning", message); -} function logStatusError(message: string): void { sendStatusConsole("error", message); } -function truncateStatusMessage(message?: string, maxLen = 180): string { - const trimmed = (message || "").trim(); - if (trimmed.length <= maxLen) { - return trimmed; - } - return trimmed.slice(0, maxLen) + "..."; -} -function logQobuzStatusReport(report: ApiStatusReport): void { - const details = Array.isArray(report.details) ? report.details : []; - if (details.length === 0) { - logStatusWarning("[Status][Qobuz] No provider details were returned."); - return; - } - const onlineCount = details.filter((detail) => detail.online === true).length; - logStatusInfo(`[Status][Qobuz] Provider check completed: ${onlineCount}/${details.length} providers online.`); - for (const detail of details) { - const label = detail.label || detail.target || "Unknown provider"; - const suffix = detail.message ? ` - ${truncateStatusMessage(detail.message)}` : ""; - if (detail.online) { - logStatusInfo(`[Status][Qobuz] ${label}: online${suffix}`); - } - else { - logStatusWarning(`[Status][Qobuz] ${label}: offline${suffix}`); - } - } - if (report.online) { - logStatusInfo(`[Status][Qobuz] SpotiFLAC Qobuz is online (${onlineCount}/${details.length} providers online).`); - } - else { - logStatusWarning(`[Status][Qobuz] SpotiFLAC Qobuz marked maintenance because all ${details.length} providers are offline.`); - } -} - function anyNextVariantUp(values: Array): ApiCheckStatus { return values.some((value) => value === "up") ? "online" : "offline"; } - function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: SpotiFLACNextSource): string[] { if (source.statusKey) { const value = payload[source.statusKey]; @@ -156,11 +88,6 @@ function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: Spoti } return values; } - -function getCurrentAmazonStatus(payload: SpotiFLACNextStatusResponse): ApiCheckStatus { - return payload[SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY] === "up" ? "online" : "offline"; -} - function getSafeNextStatusesFallback(currentStatuses: Record): Record { return SPOTIFLAC_NEXT_SOURCES.reduce>((acc, source) => { const current = currentStatuses[source.id]; @@ -168,58 +95,51 @@ function getSafeNextStatusesFallback(currentStatuses: Record { const status = apiStatusState.statuses[source.id]; return status === "online" || status === "offline"; }); } - function hasSpotiFLACNextResults(): boolean { return SPOTIFLAC_NEXT_SOURCES.some((source) => { const status = apiStatusState.nextStatuses[source.id]; return status === "online" || status === "offline"; }); } - -async function fetchSpotiFLACStatusPayloadOnce(): Promise { - const response = await withTimeout(fetch(SPOTIFLAC_STATUS_URL, { +async function fetchStatusPayloadOnce(url: string): Promise { + const response = await withTimeout(fetch(url, { method: "GET", cache: "no-store", headers: { Accept: "application/json", }, }), CHECK_TIMEOUT_MS, "SpotiFLAC status check timed out after 10 seconds"); - if (!response.ok) { throw new Error(`SpotiFLAC status returned ${response.status}`); } - return (await response.json()) as SpotiFLACNextStatusResponse; } - +async function fetchStatusPayloadWithRetry(url: string): Promise { + let lastError: unknown = null; + for (let attempt = 1; attempt <= SPOTIFLAC_STATUS_MAX_ATTEMPTS; attempt++) { + try { + return await fetchStatusPayloadOnce(url); + } + catch (error) { + lastError = error; + if (attempt < SPOTIFLAC_STATUS_MAX_ATTEMPTS) { + await delay(SPOTIFLAC_STATUS_RETRY_DELAY_MS * attempt); + } + } + } + throw lastError instanceof Error ? lastError : new Error("SpotiFLAC status check failed"); +} async function fetchSpotiFLACStatusPayload(): Promise { if (activeStatusPayloadFetch) { return activeStatusPayloadFetch; } - - activeStatusPayloadFetch = (async () => { - let lastError: unknown = null; - for (let attempt = 1; attempt <= SPOTIFLAC_STATUS_MAX_ATTEMPTS; attempt++) { - try { - return await fetchSpotiFLACStatusPayloadOnce(); - } - catch (error) { - lastError = error; - if (attempt < SPOTIFLAC_STATUS_MAX_ATTEMPTS) { - await delay(SPOTIFLAC_STATUS_RETRY_DELAY_MS * attempt); - } - } - } - throw lastError instanceof Error ? lastError : new Error("SpotiFLAC status check failed"); - })(); - + activeStatusPayloadFetch = fetchStatusPayloadWithRetry(SPOTIFLAC_STATUS_URL); try { return await activeStatusPayloadFetch; } @@ -227,42 +147,28 @@ async function fetchSpotiFLACStatusPayload(): Promise { + if (activeCurrentStatusPayloadFetch) { + return activeCurrentStatusPayloadFetch; + } + activeCurrentStatusPayloadFetch = fetchStatusPayloadWithRetry(SPOTIFLAC_CURRENT_STATUS_URL); + try { + return await activeCurrentStatusPayloadFetch; + } + finally { + activeCurrentStatusPayloadFetch = null; + } +} async function checkSourceStatus(source: ApiSource): Promise { try { - if (source.id === "tidal") { - const customTidalApi = getSettings().customTidalApi; - if (!hasConfiguredCustomTidalApi(customTidalApi)) { - logStatusWarning("[Status][Tidal] Marked maintenance because no custom Tidal instance is configured."); - return "offline"; - } - const isOnline = await withTimeout(CheckCustomTidalAPI(customTidalApi), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`); - return isOnline ? "online" : "offline"; - } - - if (source.id === "amazon") { - const payload = await fetchSpotiFLACStatusPayload(); - return getCurrentAmazonStatus(payload); - } - - if (source.id === "qobuz") { - logStatusInfo("[Status][Qobuz] Checking current SpotiFLAC providers..."); - const report = await withTimeout(CheckAPIStatusReport(source.type, source.url), CHECK_TIMEOUT_MS, `API status report timed out after 10 seconds for ${source.name}`); - logQobuzStatusReport(report); - return report.online ? "online" : "offline"; - } - - const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`); - return isOnline ? "online" : "offline"; + const payload = await fetchSpotiFLACCurrentStatusPayload(); + return payload[source.id] === "up" ? "online" : "offline"; } catch (error) { - if (source.id === "qobuz") { - logStatusError(`[Status][Qobuz] Provider check failed: ${error instanceof Error ? error.message : String(error)}`); - } + logStatusError(`[Status][${source.name}] Status check failed: ${error instanceof Error ? error.message : String(error)}`); return "offline"; } } - async function checkSpotiFLACNextStatuses(): Promise> { const payload = await fetchSpotiFLACStatusPayload(); return SPOTIFLAC_NEXT_SOURCES.reduce>((acc, source) => { @@ -270,27 +176,22 @@ async function checkSpotiFLACNextStatuses(): Promise void): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } - export async function checkCurrentApiStatusesOnly(): Promise { if (activeCheckCurrentOnly) { return activeCheckCurrentOnly; } - activeCheckCurrentOnly = (async () => { await Promise.all(API_SOURCES.map((source) => checkApiStatus(source.id))); })(); - try { await activeCheckCurrentOnly; } @@ -298,12 +199,10 @@ export async function checkCurrentApiStatusesOnly(): Promise { activeCheckCurrentOnly = null; } } - export async function checkSpotiFLACNextStatusesOnly(): Promise { if (activeCheckNextOnly) { return activeCheckNextOnly; } - activeCheckNextOnly = (async () => { const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus])); setApiStatusState((current) => ({ @@ -313,7 +212,6 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise { ...checkingNextStatuses, }, })); - try { const nextStatuses = await checkSpotiFLACNextStatuses(); setApiStatusState((current) => ({ @@ -331,7 +229,6 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise { })); } })(); - try { await activeCheckNextOnly; } @@ -339,7 +236,6 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise { activeCheckNextOnly = null; } } - export function ensureApiStatusCheckStarted(): void { if (!activeCheckCurrentOnly && !hasCurrentResults()) { void checkCurrentApiStatusesOnly(); @@ -348,22 +244,18 @@ export function ensureApiStatusCheckStarted(): void { void checkSpotiFLACNextStatusesOnly(); } } - export function ensureSpotiFLACNextStatusCheckStarted(): void { ensureApiStatusCheckStarted(); } - export async function checkApiStatus(sourceId: string): Promise { const source = API_SOURCES.find((item) => item.id === sourceId); if (!source) { return; } - const activeCheck = activeSourceChecks.get(sourceId); if (activeCheck) { return activeCheck; } - const task = (async () => { setApiStatusState((current) => ({ ...current, @@ -376,7 +268,6 @@ export async function checkApiStatus(sourceId: string): Promise { [sourceId]: "checking", }, })); - try { const status = await checkSourceStatus(source); setApiStatusState((current) => ({ @@ -398,7 +289,6 @@ export async function checkApiStatus(sourceId: string): Promise { activeSourceChecks.delete(sourceId); } })(); - activeSourceChecks.set(sourceId, task); return task; } diff --git a/frontend/src/lib/artist-links.ts b/frontend/src/lib/artist-links.ts index 8fc619a..9e3d032 100644 --- a/frontend/src/lib/artist-links.ts +++ b/frontend/src/lib/artist-links.ts @@ -40,3 +40,6 @@ export function buildClickableArtists(artists: string, artistsData?: ArtistSimpl }; }); } +export function getClickableArtistKey(artist: ClickableArtist) { + return artist.id || artist.external_urls || artist.name; +} diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index f6fc05a..b713678 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -21,6 +21,7 @@ export interface Settings { downloadPath: string; downloader: "auto" | "tidal" | "qobuz" | "amazon"; customTidalApi: string; + customQobuzApi: string; linkResolver: "songstats" | "songlink"; allowResolverFallback: boolean; theme: string; @@ -41,7 +42,7 @@ export interface Settings { operatingSystem: "Windows" | "linux/MacOS"; tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; qobuzQuality: "6" | "7" | "27"; - amazonQuality: "original"; + amazonQuality: "16" | "24"; 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; @@ -167,6 +168,7 @@ export const DEFAULT_SETTINGS: Settings = { downloadPath: "", downloader: "auto", customTidalApi: "", + customQobuzApi: "", linkResolver: "songlink", allowResolverFallback: true, theme: "yellow", @@ -184,8 +186,8 @@ export const DEFAULT_SETTINGS: Settings = { operatingSystem: detectOS(), tidalQuality: "LOSSLESS", qobuzQuality: "6", - amazonQuality: "original", - autoOrder: "qobuz-amazon", + amazonQuality: "16", + autoOrder: "tidal-qobuz-amazon", autoQuality: "16", allowFallback: true, createPlaylistFolder: true, @@ -524,11 +526,17 @@ function normalizeCustomTidalApi(value: unknown): string { export function hasConfiguredCustomTidalApi(value: unknown): boolean { return normalizeCustomTidalApi(value).startsWith("https://"); } -export function sanitizeAutoOrder(order: unknown, allowTidal: boolean): string { - const allowedServices = allowTidal - ? new Set(["tidal", "qobuz", "amazon"]) - : new Set(["qobuz", "amazon"]); - const fallbackOrder = allowTidal ? "tidal-qobuz-amazon" : "qobuz-amazon"; +function normalizeCustomQobuzApi(value: unknown): string { + return typeof value === "string" + ? value.trim().replace(/\/+$/g, "") + : ""; +} +export function hasConfiguredCustomQobuzApi(value: unknown): boolean { + return normalizeCustomQobuzApi(value).startsWith("https://"); +} +export function sanitizeAutoOrder(order: unknown): string { + const allowedServices = new Set(["tidal", "qobuz", "amazon"]); + const fallbackOrder = "tidal-qobuz-amazon"; if (typeof order !== "string") { return fallbackOrder; } @@ -538,12 +546,9 @@ export function sanitizeAutoOrder(order: unknown, allowTidal: boolean): string { .filter((part, index, parts) => part !== "" && allowedServices.has(part) && parts.indexOf(part) === index); return normalized.length >= 2 ? normalized.join("-") : fallbackOrder; } -function normalizeDownloader(value: unknown, allowTidal: boolean): Settings["downloader"] { +function normalizeDownloader(value: unknown): Settings["downloader"] { const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; - if (normalized === "tidal") { - return allowTidal ? "tidal" : "auto"; - } - if (normalized === "qobuz" || normalized === "amazon" || normalized === "auto") { + if (normalized === "tidal" || normalized === "qobuz" || normalized === "amazon" || normalized === "auto") { return normalized; } return DEFAULT_SETTINGS.downloader; @@ -607,7 +612,10 @@ function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload { normalized.qobuzQuality = "6"; } if (!("amazonQuality" in normalized)) { - normalized.amazonQuality = "original"; + normalized.amazonQuality = "16"; + } + if (normalized.amazonQuality !== "16" && normalized.amazonQuality !== "24") { + normalized.amazonQuality = "16"; } if (!("autoOrder" in normalized)) { normalized.autoOrder = DEFAULT_SETTINGS.autoOrder; @@ -616,9 +624,9 @@ function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload { normalized.autoQuality = "16"; } normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi); - const allowTidal = hasConfiguredCustomTidalApi(normalized.customTidalApi); - normalized.downloader = normalizeDownloader(normalized.downloader, allowTidal); - normalized.autoOrder = sanitizeAutoOrder(normalized.autoOrder, allowTidal); + normalized.customQobuzApi = normalizeCustomQobuzApi(normalized.customQobuzApi); + normalized.downloader = normalizeDownloader(normalized.downloader); + normalized.autoOrder = sanitizeAutoOrder(normalized.autoOrder); if (!("allowFallback" in normalized)) { normalized.allowFallback = true; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bccce25..81761a8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,9 +1,12 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { MotionConfig } from "motion/react"; import "./index.css"; import App from "./App.tsx"; import { Toaster } from "@/components/ui/sonner"; createRoot(document.getElementById("root")!).render( - - + + + + ); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 48dc659..bb08a78 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -40,6 +40,7 @@ export interface AlbumInfo { release_date: string; artists: string; images: string; + is_explicit?: boolean; upc?: string; batch?: string; } @@ -93,6 +94,7 @@ export interface DiscographyAlbum { artists: string; images: string; external_urls: string; + is_explicit?: boolean; } export interface ArtistDiscographyResponse { artist_info: ArtistInfo; @@ -120,6 +122,7 @@ export interface DownloadRequest { release_date?: string; cover_url?: string; tidal_api_url?: string; + qobuz_api_url?: string; output_dir?: string; audio_format?: string; folder_name?: string; @@ -151,6 +154,7 @@ export interface DownloadResponse { file?: string; error?: string; already_exists?: boolean; + cancelled?: boolean; item_id?: string; } export interface HealthResponse { diff --git a/go.mod b/go.mod index 69e8f4b..a8a6608 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/afkarxyz/SpotiFLAC go 1.26 require ( + github.com/Eyevinn/mp4ff v0.52.0 github.com/bogem/id3v2/v2 v2.1.4 github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacvorbis v0.2.0 diff --git a/go.sum b/go.sum index fbb24c8..91752f0 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/Eyevinn/mp4ff v0.52.0 h1:QJUi2PtROeZGkcumbX7f4/91Jz6dlhjeKzpwSdCoYG8= +github.com/Eyevinn/mp4ff v0.52.0/go.mod h1:LKZAf3K+OtWYdzlvte8uafD/e3g2aK2WcsgVohvjccU= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= @@ -17,6 +19,8 @@ github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= +github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/wails.json b/wails.json index d7ef05b..869631a 100644 --- a/wails.json +++ b/wails.json @@ -12,7 +12,7 @@ }, "info": { "productName": "SpotiFLAC", - "productVersion": "7.1.7", + "productVersion": "7.1.8", "copyright": "© 2026 afkarxyz" }, "wailsjsdir": "./frontend",