From 207c0653cc36d7ef5830707bd33bced1379186a5 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 6 Apr 2026 14:15:08 +0700 Subject: [PATCH] refactor: move deezer to extension --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 17 +- go_backend/deezer_download.go | 446 ------------- go_backend/exports.go | 103 +-- go_backend/exports_test.go | 32 + go_backend/extension_providers.go | 231 +++++-- go_backend/extension_providers_test.go | 138 +++- go_backend/extension_runtime.go | 4 + go_backend/extension_runtime_binary.go | 359 +++++++++++ go_backend/extension_runtime_binary_test.go | 185 ++++++ go_backend/extension_runtime_file.go | 199 ++++++ lib/providers/download_queue_provider.dart | 595 ++++++++++-------- lib/providers/extension_provider.dart | 4 +- lib/providers/settings_provider.dart | 7 +- lib/screens/album_screen.dart | 1 - lib/screens/artist_screen.dart | 1 - lib/screens/playlist_screen.dart | 2 - .../settings/download_settings_page.dart | 4 +- lib/services/ffmpeg_service.dart | 164 ++++- lib/widgets/download_service_picker.dart | 23 +- 19 files changed, 1665 insertions(+), 850 deletions(-) delete mode 100644 go_backend/deezer_download.go create mode 100644 go_backend/extension_runtime_binary.go create mode 100644 go_backend/extension_runtime_binary_test.go diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index bb41524..c089b72 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -944,16 +944,19 @@ class MainActivity: FlutterFragmentActivity() { ) { try { val srcFile = java.io.File(goFilePath) - if (srcFile.exists() && srcFile.length() > 0) { - contentResolver.openOutputStream(document.uri, "wt")?.use { output -> - srcFile.inputStream().use { input -> - input.copyTo(output) - } - } - srcFile.delete() + if (!srcFile.exists() || srcFile.length() <= 0) { + throw IllegalStateException("extension output missing or empty: $goFilePath") } + contentResolver.openOutputStream(document.uri, "wt")?.use { output -> + srcFile.inputStream().use { input -> + input.copyTo(output) + } + } ?: throw IllegalStateException("failed to open SAF output stream") + srcFile.delete() } catch (e: Exception) { + document.delete() android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}") + return errorJson("Failed to copy extension output to SAF: ${e.message}") } } respObj.put("file_path", document.uri.toString()) diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go deleted file mode 100644 index 8c635b2..0000000 --- a/go_backend/deezer_download.go +++ /dev/null @@ -1,446 +0,0 @@ -package gobackend - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" -) - -const deezerMusicDLURL = "https://api.zarz.moe/v1/dzr" - -type DeezerDownloadResult struct { - FilePath string - BitDepth int - SampleRate int - Title string - Artist string - Album string - ReleaseDate string - TrackNumber int - DiscNumber int - ISRC string - LyricsLRC string -} - -func isLikelySpotifyTrackID(value string) bool { - if len(value) != 22 { - return false - } - for _, r := range value { - switch { - case r >= 'A' && r <= 'Z': - case r >= 'a' && r <= 'z': - case r >= '0' && r <= '9': - default: - return false - } - } - return true -} - -func resolveDeezerTrackURL(req DownloadRequest) (string, error) { - deezerID := strings.TrimSpace(req.DeezerID) - if deezerID == "" { - if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found { - deezerID = strings.TrimSpace(prefixed) - } - } - if deezerID != "" { - trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID) - if err := verifyDeezerTrack(req, deezerID, false); err != nil { - GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err) - // Don't reject direct IDs from request payload — they're presumably correct. - } - return trackURL, nil - } - - spotifyID := strings.TrimSpace(req.SpotifyID) - if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) { - songlink := NewSongLinkClient() - availability, err := songlink.CheckTrackAvailability(spotifyID, "") - if err == nil && availability.Deezer && availability.DeezerURL != "" { - resolvedID := extractDeezerIDFromURL(availability.DeezerURL) - if resolvedID != "" { - if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil { - GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr) - // Fall through to ISRC search instead of using wrong track. - } else { - return availability.DeezerURL, nil - } - } else { - return availability.DeezerURL, nil - } - } - } - - isrc := strings.TrimSpace(req.ISRC) - if isrc != "" { - ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) - defer cancel() - track, err := GetDeezerClient().SearchByISRC(ctx, isrc) - if err == nil && track != nil { - resolvedID := songLinkExtractDeezerTrackID(track) - if resolvedID != "" { - if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil { - GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr) - return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr) - } - return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil - } - } - } - - return "", fmt.Errorf("could not resolve Deezer track URL") -} - -func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error { - ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) - defer cancel() - trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID) - if err != nil { - return nil // Can't verify — don't block the download. - } - resolved := resolvedTrackInfo{ - Title: trackResp.Track.Name, - ArtistName: trackResp.Track.Artists, - ISRC: trackResp.Track.ISRC, - Duration: trackResp.Track.DurationMS / 1000, - SkipNameVerification: skipNameVerification, - } - if !trackMatchesRequest(req, resolved, "Deezer") { - return fmt.Errorf("expected '%s - %s', got '%s - %s'", - req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title) - } - GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title) - return nil -} - -type deezerMusicDLRequest struct { - Platform string `json:"platform"` - URL string `json:"url"` -} - -func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) { - payload := deezerMusicDLRequest{ - Platform: "deezer", - URL: deezerTrackURL, - } - jsonData, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("failed to encode MusicDL request: %w", err) - } - - req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData)) - if err != nil { - return "", fmt.Errorf("failed to create MusicDL request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("MusicDL request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) - if err != nil { - return "", fmt.Errorf("failed to read MusicDL response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - return "", fmt.Errorf("invalid MusicDL JSON: %w", err) - } - - if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" { - return "", fmt.Errorf("MusicDL error: %s", errMsg) - } - - for _, key := range []string{"download_url", "url", "link"} { - if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" { - return strings.TrimSpace(urlVal), nil - } - } - if data, ok := raw["data"].(map[string]any); ok { - for _, key := range []string{"download_url", "url", "link"} { - if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" { - return strings.TrimSpace(urlVal), nil - } - } - } - - return "", fmt.Errorf("no download URL found in MusicDL response") -} - -func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error { - GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL) - - downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL) - if err != nil { - return err - } - GoLog("[Deezer] MusicDL returned download URL, starting download...\n") - - ctx := context.Background() - if itemID != "" { - StartItemProgress(itemID) - defer CompleteItemProgress(itemID) - ctx = initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - } - - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - - req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) - if err != nil { - return fmt.Errorf("failed to create download request: %w", err) - } - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := GetDownloadClient().Do(req) - if err != nil { - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return fmt.Errorf("download request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download returned HTTP %d", resp.StatusCode) - } - - expectedSize := resp.ContentLength - if expectedSize > 0 && itemID != "" { - SetItemBytesTotal(itemID, expectedSize) - } - - out, err := openOutputForWrite(outputPath, outputFD) - if err != nil { - return err - } - - bufWriter := bufio.NewWriterSize(out, 256*1024) - var written int64 - if itemID != "" { - pw := NewItemProgressWriter(bufWriter, itemID) - written, err = io.Copy(pw, resp.Body) - } else { - written, err = io.Copy(bufWriter, resp.Body) - } - - flushErr := bufWriter.Flush() - closeErr := out.Close() - - if err != nil { - cleanupOutputOnError(outputPath, outputFD) - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return fmt.Errorf("download interrupted: %w", err) - } - if flushErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to flush output: %w", flushErr) - } - if closeErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to close output: %w", closeErr) - } - - if expectedSize > 0 && written != expectedSize { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) - } - - GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024)) - return nil -} - -func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) { - deezerClient := GetDeezerClient() - isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" - - if !isSafOutput { - if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil - } - } - - filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ - "title": req.TrackName, - "artist": req.ArtistName, - "album": req.AlbumName, - "track": req.TrackNumber, - "year": extractYear(req.ReleaseDate), - "date": req.ReleaseDate, - "disc": req.DiscNumber, - }) - - var outputPath string - if isSafOutput { - outputPath = strings.TrimSpace(req.OutputPath) - if outputPath == "" && isFDOutput(req.OutputFD) { - outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) - } - } else { - filename = sanitizeFilename(filename) + ".flac" - outputPath = filepath.Join(req.OutputDir, filename) - if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { - return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil - } - } - - var parallelResult *ParallelDownloadResult - parallelDone := make(chan struct{}) - go func() { - defer close(parallelDone) - coverURL := req.CoverURL - embedLyrics := req.EmbedLyrics - if !req.EmbedMetadata { - coverURL = "" - embedLyrics = false - } - parallelResult = FetchCoverAndLyricsParallel( - coverURL, - req.EmbedMaxQualityCover, - req.SpotifyID, - req.TrackName, - req.ArtistName, - embedLyrics, - int64(req.DurationMS), - ) - }() - - deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req) - if deezerURLErr != nil { - return DeezerDownloadResult{}, fmt.Errorf( - "deezer download failed: could not resolve Deezer URL: %w", - deezerURLErr, - ) - } - - GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL) - downloadErr := deezerClient.DownloadFromMusicDL( - deezerTrackURL, - outputPath, - req.OutputFD, - req.ItemID, - ) - if downloadErr != nil { - if errors.Is(downloadErr, ErrDownloadCancelled) { - return DeezerDownloadResult{}, ErrDownloadCancelled - } - return DeezerDownloadResult{}, fmt.Errorf( - "deezer download failed via MusicDL: %w", - downloadErr, - ) - } - - <-parallelDone - - if req.ItemID != "" { - SetItemProgress(req.ItemID, 1.0, 0, 0) - SetItemFinalizing(req.ItemID) - } - - metadata := Metadata{ - Title: req.TrackName, - Artist: req.ArtistName, - Album: req.AlbumName, - AlbumArtist: req.AlbumArtist, - ArtistTagMode: req.ArtistTagMode, - Date: req.ReleaseDate, - TrackNumber: req.TrackNumber, - TotalTracks: req.TotalTracks, - DiscNumber: req.DiscNumber, - TotalDiscs: req.TotalDiscs, - ISRC: req.ISRC, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, - Composer: req.Composer, - } - - var coverData []byte - if parallelResult != nil && parallelResult.CoverData != nil { - coverData = parallelResult.CoverData - } - - if isSafOutput || !req.EmbedMetadata { - if !req.EmbedMetadata { - GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n") - } else { - GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") - } - } else { - if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { - GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err) - } - - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsMode := req.LyricsMode - if lyricsMode == "" { - lyricsMode = "embed" - } - - if lyricsMode == "external" || lyricsMode == "both" { - if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { - GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr) - } else { - GoLog("[Deezer] LRC file saved: %s\n", lrcPath) - } - } - - if lyricsMode == "embed" || lyricsMode == "both" { - if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { - GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr) - } - } - } - } - - if !isSafOutput { - AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) - } - - bitDepth, sampleRate := 0, 0 - if quality, qErr := GetAudioQuality(outputPath); qErr == nil { - bitDepth = quality.BitDepth - sampleRate = quality.SampleRate - } - - lyricsLRC := "" - if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsLRC = parallelResult.LyricsLRC - } - - return DeezerDownloadResult{ - FilePath: outputPath, - BitDepth: bitDepth, - SampleRate: sampleRate, - Title: req.TrackName, - Artist: req.ArtistName, - Album: req.AlbumName, - ReleaseDate: req.ReleaseDate, - TrackNumber: req.TrackNumber, - DiscNumber: req.DiscNumber, - ISRC: req.ISRC, - LyricsLRC: lyricsLRC, - }, nil -} diff --git a/go_backend/exports.go b/go_backend/exports.go index fe63651..c68fb39 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -74,33 +74,34 @@ type DownloadRequest struct { } type DownloadResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - FilePath string `json:"file_path,omitempty"` - Error string `json:"error,omitempty"` - ErrorType string `json:"error_type,omitempty"` - AlreadyExists bool `json:"already_exists,omitempty"` - ActualBitDepth int `json:"actual_bit_depth,omitempty"` - ActualSampleRate int `json:"actual_sample_rate,omitempty"` - Service string `json:"service,omitempty"` - Title string `json:"title,omitempty"` - Artist string `json:"artist,omitempty"` - Album string `json:"album,omitempty"` - AlbumArtist string `json:"album_artist,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - TrackNumber int `json:"track_number,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - TotalTracks int `json:"total_tracks,omitempty"` - TotalDiscs int `json:"total_discs,omitempty"` - ISRC string `json:"isrc,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - Genre string `json:"genre,omitempty"` - Label string `json:"label,omitempty"` - Copyright string `json:"copyright,omitempty"` - Composer string `json:"composer,omitempty"` - SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` - LyricsLRC string `json:"lyrics_lrc,omitempty"` - DecryptionKey string `json:"decryption_key,omitempty"` + Success bool `json:"success"` + Message string `json:"message"` + FilePath string `json:"file_path,omitempty"` + Error string `json:"error,omitempty"` + ErrorType string `json:"error_type,omitempty"` + AlreadyExists bool `json:"already_exists,omitempty"` + ActualBitDepth int `json:"actual_bit_depth,omitempty"` + ActualSampleRate int `json:"actual_sample_rate,omitempty"` + Service string `json:"service,omitempty"` + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + TotalTracks int `json:"total_tracks,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` + ISRC string `json:"isrc,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` + Composer string `json:"composer,omitempty"` + SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` + LyricsLRC string `json:"lyrics_lrc,omitempty"` + DecryptionKey string `json:"decryption_key,omitempty"` + Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"` } type DownloadResult struct { @@ -123,6 +124,7 @@ type DownloadResult struct { Composer string LyricsLRC string DecryptionKey string + Decryption *DownloadDecryptionInfo } type reEnrichRequest struct { @@ -634,6 +636,7 @@ func buildDownloadSuccessResponse( Composer: composer, LyricsLRC: result.LyricsLRC, DecryptionKey: result.DecryptionKey, + Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), } } @@ -781,24 +784,6 @@ func DownloadTrack(requestJSON string) (string, error) { } } err = qobuzErr - case "deezer": - deezerResult, deezerErr := downloadFromDeezer(req) - if deezerErr == nil { - result = DownloadResult{ - FilePath: deezerResult.FilePath, - BitDepth: deezerResult.BitDepth, - SampleRate: deezerResult.SampleRate, - Title: deezerResult.Title, - Artist: deezerResult.Artist, - Album: deezerResult.Album, - ReleaseDate: deezerResult.ReleaseDate, - TrackNumber: deezerResult.TrackNumber, - DiscNumber: deezerResult.DiscNumber, - ISRC: deezerResult.ISRC, - LyricsLRC: deezerResult.LyricsLRC, - } - } - err = deezerErr default: return errorResponse("Unknown service: " + req.Service) } @@ -849,7 +834,7 @@ func DownloadByStrategy(requestJSON string) (string, error) { serviceNormalized := strings.ToLower(serviceRaw) normalizedReq := req - if isBuiltInProvider(serviceNormalized) { + if isBuiltInDownloadProvider(serviceNormalized) { normalizedReq.Service = serviceNormalized } @@ -862,7 +847,7 @@ func DownloadByStrategy(requestJSON string) (string, error) { if req.UseExtensions { // Respect strict mode when auto fallback is disabled: // for built-in providers, route directly to selected service only. - if !req.UseFallback && isBuiltInProvider(serviceNormalized) { + if !req.UseFallback && isBuiltInDownloadProvider(serviceNormalized) { return DownloadTrack(normalizedJSON) } resp, err := DownloadWithExtensionsJSON(normalizedJSON) @@ -901,9 +886,9 @@ func DownloadWithFallback(requestJSON string) (string, error) { enrichRequestExtendedMetadata(&req) - allServices := []string{"tidal", "qobuz", "deezer"} + allServices := []string{"tidal", "qobuz"} preferredService := req.Service - if preferredService == "" { + if !isBuiltInDownloadProvider(preferredService) { preferredService = "tidal" } @@ -969,26 +954,6 @@ func DownloadWithFallback(requestJSON string) (string, error) { GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) } err = qobuzErr - case "deezer": - deezerResult, deezerErr := downloadFromDeezer(req) - if deezerErr == nil { - result = DownloadResult{ - FilePath: deezerResult.FilePath, - BitDepth: deezerResult.BitDepth, - SampleRate: deezerResult.SampleRate, - Title: deezerResult.Title, - Artist: deezerResult.Artist, - Album: deezerResult.Album, - ReleaseDate: deezerResult.ReleaseDate, - TrackNumber: deezerResult.TrackNumber, - DiscNumber: deezerResult.DiscNumber, - ISRC: deezerResult.ISRC, - LyricsLRC: deezerResult.LyricsLRC, - } - } else if !errors.Is(deezerErr, ErrDownloadCancelled) { - GoLog("[DownloadWithFallback] Deezer error: %v\n", deezerErr) - } - err = deezerErr } if err != nil && errors.Is(err, ErrDownloadCancelled) { diff --git a/go_backend/exports_test.go b/go_backend/exports_test.go index 416305c..7ae5984 100644 --- a/go_backend/exports_test.go +++ b/go_backend/exports_test.go @@ -129,6 +129,38 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) { } } +func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) { + req := DownloadRequest{ + TrackName: "Track", + ArtistName: "Artist", + } + + result := DownloadResult{ + Title: "Track", + Artist: "Artist", + DecryptionKey: "00112233", + } + + resp := buildDownloadSuccessResponse( + req, + result, + "amazon", + "ok", + "/tmp/test.m4a", + false, + ) + + if resp.Decryption == nil { + t.Fatal("expected decryption descriptor to be present") + } + if resp.Decryption.Strategy != genericFFmpegMOVDecryptionStrategy { + t.Fatalf("strategy = %q", resp.Decryption.Strategy) + } + if resp.Decryption.Key != result.DecryptionKey { + t.Fatalf("key = %q, want %q", resp.Decryption.Key, result.DecryptionKey) + } +} + func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) { req := reEnrichRequest{ SpotifyID: "spotify-track-id", diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index d00bd88..f48a443 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -96,6 +96,15 @@ type ExtDownloadURLResult struct { SampleRate int `json:"sample_rate,omitempty"` } +type DownloadDecryptionInfo struct { + Strategy string `json:"strategy,omitempty"` + Key string `json:"key,omitempty"` + IV string `json:"iv,omitempty"` + InputFormat string `json:"input_format,omitempty"` + OutputExtension string `json:"output_extension,omitempty"` + Options map[string]interface{} `json:"options,omitempty"` +} + type ExtDownloadResult struct { Success bool `json:"success"` FilePath string `json:"file_path,omitempty"` @@ -104,16 +113,90 @@ type ExtDownloadResult struct { ErrorMessage string `json:"error_message,omitempty"` ErrorType string `json:"error_type,omitempty"` - Title string `json:"title,omitempty"` - Artist string `json:"artist,omitempty"` - Album string `json:"album,omitempty"` - AlbumArtist string `json:"album_artist,omitempty"` - TrackNumber int `json:"track_number,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - ISRC string `json:"isrc,omitempty"` - DecryptionKey string `json:"decryption_key,omitempty"` + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + ISRC string `json:"isrc,omitempty"` + DecryptionKey string `json:"decryption_key,omitempty"` + Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"` +} + +const genericFFmpegMOVDecryptionStrategy = "ffmpeg.mov_key" + +func cloneDownloadDecryptionInfo(info *DownloadDecryptionInfo) *DownloadDecryptionInfo { + if info == nil { + return nil + } + + cloned := &DownloadDecryptionInfo{ + Strategy: strings.TrimSpace(info.Strategy), + Key: strings.TrimSpace(info.Key), + IV: strings.TrimSpace(info.IV), + InputFormat: strings.TrimSpace(info.InputFormat), + OutputExtension: strings.TrimSpace(info.OutputExtension), + } + if len(info.Options) > 0 { + cloned.Options = make(map[string]interface{}, len(info.Options)) + for key, value := range info.Options { + cloned.Options[key] = value + } + } + return cloned +} + +func normalizeDownloadDecryptionStrategy(strategy string) string { + switch strings.ToLower(strings.TrimSpace(strategy)) { + case "", "ffmpeg.mov_key", "ffmpeg_mov_key", "mov_decryption_key", "mp4_decryption_key", "ffmpeg.mp4_decryption_key": + return genericFFmpegMOVDecryptionStrategy + default: + return strings.TrimSpace(strategy) + } +} + +func normalizeDownloadDecryptionInfo(info *DownloadDecryptionInfo, legacyKey string) *DownloadDecryptionInfo { + normalized := cloneDownloadDecryptionInfo(info) + trimmedLegacyKey := strings.TrimSpace(legacyKey) + + if normalized == nil { + if trimmedLegacyKey == "" { + return nil + } + return &DownloadDecryptionInfo{ + Strategy: genericFFmpegMOVDecryptionStrategy, + Key: trimmedLegacyKey, + InputFormat: "mov", + } + } + + normalized.Strategy = normalizeDownloadDecryptionStrategy(normalized.Strategy) + if normalized.Key == "" && trimmedLegacyKey != "" { + normalized.Key = trimmedLegacyKey + } + if normalized.Strategy == "" && normalized.Key != "" { + normalized.Strategy = genericFFmpegMOVDecryptionStrategy + } + if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.InputFormat == "" { + normalized.InputFormat = "mov" + } + if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.Key == "" { + return nil + } + + return normalized +} + +func normalizedDownloadDecryptionKey(info *DownloadDecryptionInfo, legacyKey string) string { + if normalized := normalizeDownloadDecryptionInfo(info, legacyKey); normalized != nil { + if normalized.Strategy == genericFFmpegMOVDecryptionStrategy { + return normalized.Key + } + } + return strings.TrimSpace(legacyKey) } type extensionProviderWrapper struct { @@ -600,6 +683,14 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID ErrorType: "internal_error", }, nil } + downloadResult.Decryption = normalizeDownloadDecryptionInfo( + downloadResult.Decryption, + downloadResult.DecryptionKey, + ) + downloadResult.DecryptionKey = normalizedDownloadDecryptionKey( + downloadResult.Decryption, + downloadResult.DecryptionKey, + ) return &downloadResult, nil } @@ -687,8 +778,8 @@ var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks func SetProviderPriority(providerIDs []string) { providerPriorityMu.Lock() defer providerPriorityMu.Unlock() - providerPriority = providerIDs - GoLog("[Extension] Download provider priority set: %v\n", providerIDs) + providerPriority = sanitizeDownloadProviderPriority(providerIDs) + GoLog("[Extension] Download provider priority set: %v\n", providerPriority) } func GetProviderPriority() []string { @@ -696,7 +787,7 @@ func GetProviderPriority() []string { defer providerPriorityMu.RUnlock() if len(providerPriority) == 0 { - return []string{"tidal", "qobuz", "deezer"} + return []string{"tidal", "qobuz"} } result := make([]string, len(providerPriority)) @@ -704,6 +795,43 @@ func GetProviderPriority() []string { return result } +func sanitizeDownloadProviderPriority(providerIDs []string) []string { + sanitized := make([]string, 0, len(providerIDs)+2) + seen := map[string]struct{}{} + + for _, providerID := range providerIDs { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + continue + } + + normalizedBuiltIn := strings.ToLower(providerID) + if normalizedBuiltIn == "deezer" { + continue + } + if isBuiltInDownloadProvider(normalizedBuiltIn) { + providerID = normalizedBuiltIn + } + + seenKey := strings.ToLower(providerID) + if _, exists := seen[seenKey]; exists { + continue + } + seen[seenKey] = struct{}{} + sanitized = append(sanitized, providerID) + } + + for _, providerID := range []string{"tidal", "qobuz"} { + if _, exists := seen[providerID]; exists { + continue + } + seen[providerID] = struct{}{} + sanitized = append(sanitized, providerID) + } + + return sanitized +} + func SetExtensionFallbackProviderIDs(providerIDs []string) { extensionFallbackProviderIDsMu.Lock() defer extensionFallbackProviderIDsMu.Unlock() @@ -718,7 +846,7 @@ func SetExtensionFallbackProviderIDs(providerIDs []string) { seen := map[string]struct{}{} for _, providerID := range providerIDs { providerID = strings.TrimSpace(providerID) - if providerID == "" || isBuiltInProvider(strings.ToLower(providerID)) { + if providerID == "" || isBuiltInDownloadProvider(strings.ToLower(providerID)) { continue } if _, exists := seen[providerID]; exists { @@ -746,7 +874,7 @@ func GetExtensionFallbackProviderIDs() []string { } func isExtensionFallbackAllowed(providerID string) bool { - if isBuiltInProvider(strings.ToLower(providerID)) { + if isBuiltInDownloadProvider(strings.ToLower(providerID)) { return true } @@ -814,6 +942,15 @@ func isBuiltInProvider(providerID string) bool { } } +func isBuiltInDownloadProvider(providerID string) bool { + switch providerID { + case "tidal", "qobuz": + return true + default: + return false + } +} + func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata { deezerID := "" tidalID := "" @@ -992,7 +1129,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } } - if !strictMode && req.Service != "" && isBuiltInProvider(strings.ToLower(req.Service)) { + if !strictMode && req.Service != "" && isBuiltInDownloadProvider(strings.ToLower(req.Service)) { GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service) newPriority := []string{req.Service} for _, p := range priority { @@ -1002,7 +1139,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } priority = newPriority GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority) - } else if !strictMode && req.Service != "" && !isBuiltInProvider(strings.ToLower(req.Service)) { + } else if !strictMode && req.Service != "" && !isBuiltInDownloadProvider(strings.ToLower(req.Service)) { found := false for _, p := range priority { if strings.EqualFold(p, req.Service) { @@ -1260,14 +1397,17 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Label: req.Label, Copyright: req.Copyright, DecryptionKey: result.DecryptionKey, + Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), } - if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { + if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) { if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) } else { GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) } + } else if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { + GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", result.FilePath) } if ext.Manifest.SkipMetadataEnrichment { @@ -1365,19 +1505,19 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro continue } - if skipBuiltIn && isBuiltInProvider(providerIDNormalized) { + if skipBuiltIn && isBuiltInDownloadProvider(providerIDNormalized) { GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID) continue } - if !isBuiltInProvider(providerIDNormalized) && !isExtensionFallbackAllowed(providerID) { + if !isBuiltInDownloadProvider(providerIDNormalized) && !isExtensionFallbackAllowed(providerID) { GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID) continue } GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) - if isBuiltInProvider(providerIDNormalized) { + if isBuiltInDownloadProvider(providerIDNormalized) { if (req.Genre == "" || req.Label == "" || req.Copyright == "") && req.ISRC != "" { GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC) @@ -1491,14 +1631,17 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Label: req.Label, Copyright: req.Copyright, DecryptionKey: result.DecryptionKey, + Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), } - if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { + if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) { if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) } else { GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) } + } else if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { + GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", result.FilePath) } if ext.Manifest.SkipMetadataEnrichment { @@ -1631,24 +1774,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon } } err = qobuzErr - case "deezer": - deezerResult, deezerErr := downloadFromDeezer(req) - if deezerErr == nil { - result = DownloadResult{ - FilePath: deezerResult.FilePath, - BitDepth: deezerResult.BitDepth, - SampleRate: deezerResult.SampleRate, - Title: deezerResult.Title, - Artist: deezerResult.Artist, - Album: deezerResult.Album, - ReleaseDate: deezerResult.ReleaseDate, - TrackNumber: deezerResult.TrackNumber, - DiscNumber: deezerResult.DiscNumber, - ISRC: deezerResult.ISRC, - LyricsLRC: deezerResult.LyricsLRC, - } - } - err = deezerErr default: return nil, fmt.Errorf("unknown built-in provider: %s", providerID) } @@ -1676,6 +1801,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon Copyright: req.Copyright, LyricsLRC: result.LyricsLRC, DecryptionKey: result.DecryptionKey, + Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), }, nil } @@ -1717,19 +1843,24 @@ func buildOutputPath(req DownloadRequest) string { outputDir := req.OutputDir if strings.TrimSpace(outputDir) == "" { outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads") - os.MkdirAll(outputDir, 0755) - AddAllowedDownloadDir(outputDir) } + os.MkdirAll(outputDir, 0755) + AddAllowedDownloadDir(outputDir) return filepath.Join(outputDir, filename+ext) } func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string { if strings.TrimSpace(req.OutputPath) != "" { - return strings.TrimSpace(req.OutputPath) + outputPath := strings.TrimSpace(req.OutputPath) + AddAllowedDownloadDir(filepath.Dir(outputPath)) + return outputPath } - if strings.TrimSpace(req.OutputDir) != "" { + // SAF downloads hand extensions a detached output FD owned by the host. + // Extensions still need a real local temp file so Android can copy it into + // the target document after provider-specific post-processing completes. + if !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" { return buildOutputPath(req) } @@ -1770,6 +1901,18 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri return filepath.Join(tempDir, filename+outputExt) } +func canEmbedGenreLabel(filePath string) bool { + path := strings.TrimSpace(filePath) + if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") { + return false + } + if !filepath.IsAbs(path) { + return false + } + info, err := os.Stat(path) + return err == nil && !info.IsDir() && info.Size() > 0 +} + func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { if !p.extension.Manifest.HasCustomSearch() { return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID) diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go index b946889..2a3eac7 100644 --- a/go_backend/extension_providers_test.go +++ b/go_backend/extension_providers_test.go @@ -1,6 +1,10 @@ package gobackend -import "testing" +import ( + "os" + "path/filepath" + "testing" +) func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) { original := GetMetadataProviderPriority() @@ -63,8 +67,136 @@ func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) { if isExtensionFallbackAllowed("blocked-ext") { t.Fatal("expected extension outside allowlist to be blocked") } - if !isExtensionFallbackAllowed("deezer") { - t.Fatal("expected built-in provider to ignore extension allowlist") + if isExtensionFallbackAllowed("deezer") { + t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist") + } +} + +func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) { + original := GetProviderPriority() + defer SetProviderPriority(original) + + SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"}) + + got := GetProviderPriority() + want := []string{"qobuz", "custom-ext", "tidal"} + if len(got) != len(want) { + t.Fatalf("unexpected priority length: got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want) + } + } +} + +func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) { + normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ") + if normalized == nil { + t.Fatal("expected legacy decryption key to produce normalized descriptor") + } + if normalized.Strategy != genericFFmpegMOVDecryptionStrategy { + t.Fatalf("strategy = %q", normalized.Strategy) + } + if normalized.Key != "001122" { + t.Fatalf("key = %q", normalized.Key) + } + if normalized.InputFormat != "mov" { + t.Fatalf("input format = %q", normalized.InputFormat) + } +} + +func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) { + normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{ + Strategy: "mp4_decryption_key", + Key: "abcd", + InputFormat: "", + }, "") + if normalized == nil { + t.Fatal("expected descriptor to remain available") + } + if normalized.Strategy != genericFFmpegMOVDecryptionStrategy { + t.Fatalf("strategy = %q", normalized.Strategy) + } + if normalized.InputFormat != "mov" { + t.Fatalf("input format = %q", normalized.InputFormat) + } +} + +func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) { + SetAllowedDownloadDirs(nil) + + outputDir := t.TempDir() + outputPath := buildOutputPath(DownloadRequest{ + TrackName: "Song", + ArtistName: "Artist", + OutputDir: outputDir, + OutputExt: ".flac", + FilenameFormat: "", + }) + + if !isPathInAllowedDirs(outputPath) { + t.Fatalf("expected output path %q to be allowed", outputPath) + } +} + +func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) { + SetAllowedDownloadDirs(nil) + + outputDir := t.TempDir() + outputPath := filepath.Join(outputDir, "custom.flac") + ext := &loadedExtension{DataDir: t.TempDir()} + + resolved := buildOutputPathForExtension(DownloadRequest{ + OutputPath: outputPath, + }, ext) + + if resolved != outputPath { + t.Fatalf("resolved output path = %q", resolved) + } + if !isPathInAllowedDirs(outputPath) { + t.Fatalf("expected output path %q to be allowed", outputPath) + } +} + +func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) { + SetAllowedDownloadDirs(nil) + + ext := &loadedExtension{DataDir: t.TempDir()} + resolved := buildOutputPathForExtension(DownloadRequest{ + TrackName: "Song", + ArtistName: "Artist", + OutputDir: filepath.Join("Artist", "Album"), + OutputFD: 123, + OutputExt: ".flac", + }, ext) + + expectedBase := filepath.Join(ext.DataDir, "downloads") + if !isPathWithinBase(expectedBase, resolved) { + t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved) + } + if !isPathInAllowedDirs(resolved) { + t.Fatalf("expected resolved output path %q to be allowed", resolved) + } +} + +func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) { + tempFile := filepath.Join(t.TempDir(), "track.flac") + if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + if canEmbedGenreLabel("relative.flac") { + t.Fatal("expected relative path to be rejected") + } + if canEmbedGenreLabel("content://example") { + t.Fatal("expected content URI to be rejected") + } + if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) { + t.Fatal("expected missing file to be rejected") + } + if !canEmbedGenreLabel(tempFile) { + t.Fatalf("expected existing absolute file %q to be accepted", tempFile) } } diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 2bb18cf..f02e952 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -377,7 +377,9 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) { fileObj.Set("exists", r.fileExists) fileObj.Set("delete", r.fileDelete) fileObj.Set("read", r.fileRead) + fileObj.Set("readBytes", r.fileReadBytes) fileObj.Set("write", r.fileWrite) + fileObj.Set("writeBytes", r.fileWriteBytes) fileObj.Set("copy", r.fileCopy) fileObj.Set("move", r.fileMove) fileObj.Set("getSize", r.fileGetSize) @@ -407,6 +409,8 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) { utilsObj.Set("stringifyJSON", r.stringifyJSON) utilsObj.Set("encrypt", r.cryptoEncrypt) utilsObj.Set("decrypt", r.cryptoDecrypt) + utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher) + utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher) utilsObj.Set("generateKey", r.cryptoGenerateKey) utilsObj.Set("randomUserAgent", r.randomUserAgent) vm.Set("utils", utilsObj) diff --git a/go_backend/extension_runtime_binary.go b/go_backend/extension_runtime_binary.go new file mode 100644 index 0000000..0753542 --- /dev/null +++ b/go_backend/extension_runtime_binary.go @@ -0,0 +1,359 @@ +package gobackend + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "github.com/dop251/goja" + "golang.org/x/crypto/blowfish" +) + +type runtimeBlockCipherOptions struct { + Algorithm string + Mode string + Key []byte + IV []byte + InputEncoding string + OutputEncoding string + Padding string +} + +func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} { + if len(call.Arguments) <= index { + return nil + } + + value := call.Arguments[index] + if goja.IsUndefined(value) || goja.IsNull(value) { + return nil + } + + exported := value.Export() + if options, ok := exported.(map[string]interface{}); ok { + return options + } + return nil +} + +func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string { + if options == nil { + return defaultValue + } + raw, ok := options[key] + if !ok || raw == nil { + return defaultValue + } + switch value := raw.(type) { + case string: + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + case []byte: + if len(value) > 0 { + return string(value) + } + } + return defaultValue +} + +func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool { + if options == nil { + return defaultValue + } + raw, ok := options[key] + if !ok || raw == nil { + return defaultValue + } + switch value := raw.(type) { + case bool: + return value + case int: + return value != 0 + case int64: + return value != 0 + case float64: + return value != 0 + case string: + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + } + } + return defaultValue +} + +func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 { + if options == nil { + return defaultValue + } + raw, ok := options[key] + if !ok || raw == nil { + return defaultValue + } + switch value := raw.(type) { + case int: + return int64(value) + case int32: + return int64(value) + case int64: + return value + case float32: + return int64(value) + case float64: + return int64(value) + case string: + value = strings.TrimSpace(value) + if value == "" { + return defaultValue + } + var parsed int64 + if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil { + return parsed + } + } + return defaultValue +} + +func runtimeOptionHasKey(options map[string]interface{}, key string) bool { + if options == nil { + return false + } + _, exists := options[key] + return exists +} + +func decodeRuntimeBytesString(input, encoding string) ([]byte, error) { + switch strings.ToLower(strings.TrimSpace(encoding)) { + case "", "utf8", "utf-8", "text": + return []byte(input), nil + case "base64": + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(input)) + if err != nil { + return nil, fmt.Errorf("invalid base64 data: %w", err) + } + return decoded, nil + case "hex": + decoded, err := hex.DecodeString(strings.TrimSpace(input)) + if err != nil { + return nil, fmt.Errorf("invalid hex data: %w", err) + } + return decoded, nil + default: + return nil, fmt.Errorf("unsupported byte encoding: %s", encoding) + } +} + +func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) { + switch value := raw.(type) { + case string: + return decodeRuntimeBytesString(value, encoding) + case []byte: + cloned := make([]byte, len(value)) + copy(cloned, value) + return cloned, nil + case []interface{}: + decoded := make([]byte, len(value)) + for i, item := range value { + switch num := item.(type) { + case int: + decoded[i] = byte(num) + case int64: + decoded[i] = byte(num) + case float64: + decoded[i] = byte(int(num)) + default: + return nil, fmt.Errorf("unsupported byte array item at index %d", i) + } + } + return decoded, nil + default: + return nil, fmt.Errorf("unsupported byte payload type") + } +} + +func encodeRuntimeBytes(data []byte, encoding string) (string, error) { + switch strings.ToLower(strings.TrimSpace(encoding)) { + case "", "base64": + return base64.StdEncoding.EncodeToString(data), nil + case "hex": + return hex.EncodeToString(data), nil + case "utf8", "utf-8", "text": + return string(data), nil + default: + return "", fmt.Errorf("unsupported byte encoding: %s", encoding) + } +} + +func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) { + parsed := &runtimeBlockCipherOptions{ + Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")), + Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")), + InputEncoding: strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64")), + OutputEncoding: strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64")), + Padding: strings.ToLower(runtimeOptionString(options, "padding", "none")), + } + if parsed.Algorithm == "" { + return nil, fmt.Errorf("algorithm is required") + } + if parsed.Mode == "" { + return nil, fmt.Errorf("mode is required") + } + + key, err := decodeRuntimeBytesString(runtimeOptionString(options, "key", ""), runtimeOptionString(options, "keyEncoding", "utf8")) + if err != nil { + return nil, fmt.Errorf("invalid key: %w", err) + } + if len(key) == 0 { + return nil, fmt.Errorf("key is required") + } + parsed.Key = key + + iv, err := decodeRuntimeBytesString(runtimeOptionString(options, "iv", ""), runtimeOptionString(options, "ivEncoding", "utf8")) + if err != nil { + return nil, fmt.Errorf("invalid iv: %w", err) + } + parsed.IV = iv + return parsed, nil +} + +func newRuntimeBlockCipher(options *runtimeBlockCipherOptions) (cipher.Block, error) { + switch options.Algorithm { + case "blowfish": + return blowfish.NewCipher(options.Key) + case "aes": + return aes.NewCipher(options.Key) + default: + return nil, fmt.Errorf("unsupported block cipher algorithm: %s", options.Algorithm) + } +} + +func applyPKCS7Padding(data []byte, blockSize int) []byte { + padding := blockSize - (len(data) % blockSize) + if padding == 0 { + padding = blockSize + } + out := make([]byte, len(data)+padding) + copy(out, data) + for i := len(data); i < len(out); i++ { + out[i] = byte(padding) + } + return out +} + +func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) { + if len(data) == 0 || len(data)%blockSize != 0 { + return nil, fmt.Errorf("invalid padded payload length") + } + padding := int(data[len(data)-1]) + if padding <= 0 || padding > blockSize || padding > len(data) { + return nil, fmt.Errorf("invalid PKCS7 padding") + } + for i := len(data) - padding; i < len(data); i++ { + if int(data[i]) != padding { + return nil, fmt.Errorf("invalid PKCS7 padding") + } + } + return data[:len(data)-padding], nil +} + +func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "data and options are required", + }) + } + + options := parseRuntimeOptionsArgument(call, 1) + parsedOptions, err := parseRuntimeBlockCipherOptions(options) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + if parsedOptions.Mode != "cbc" { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode), + }) + } + + inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + block, err := newRuntimeBlockCipher(parsedOptions) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + if len(parsedOptions.IV) != block.BlockSize() { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm), + }) + } + + data := inputData + if !decrypt && parsedOptions.Padding == "pkcs7" { + data = applyPKCS7Padding(data, block.BlockSize()) + } + if len(data)%block.BlockSize() != 0 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()), + }) + } + + output := make([]byte, len(data)) + if decrypt { + cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data) + if parsedOptions.Padding == "pkcs7" { + output, err = removePKCS7Padding(output, block.BlockSize()) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + } + } else { + cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data) + } + + encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "data": encoded, + "block_size": block.BlockSize(), + }) +} + +func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value { + return r.transformBlockCipher(call, false) +} + +func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value { + return r.transformBlockCipher(call, true) +} diff --git a/go_backend/extension_runtime_binary_test.go b/go_backend/extension_runtime_binary_test.go new file mode 100644 index 0000000..d69dad5 --- /dev/null +++ b/go_backend/extension_runtime_binary_test.go @@ -0,0 +1,185 @@ +package gobackend + +import ( + "encoding/json" + "testing" + + "github.com/dop251/goja" +) + +func newBinaryTestRuntime(t *testing.T, withFilePermission bool) *goja.Runtime { + t.Helper() + + ext := &loadedExtension{ + ID: "binary-test-ext", + Manifest: &ExtensionManifest{ + Name: "binary-test-ext", + Permissions: ExtensionPermissions{ + File: withFilePermission, + }, + }, + DataDir: t.TempDir(), + } + + runtime := newExtensionRuntime(ext) + vm := goja.New() + runtime.RegisterAPIs(vm) + return vm +} + +func decodeJSONResult[T any](t *testing.T, value goja.Value) T { + t.Helper() + + var decoded T + if err := json.Unmarshal([]byte(value.String()), &decoded); err != nil { + t.Fatalf("failed to decode JSON result: %v", err) + } + return decoded +} + +func TestExtensionRuntime_FileByteAPIs(t *testing.T) { + vm := newBinaryTestRuntime(t, true) + + result, err := vm.RunString(` + (function() { + var first = file.writeBytes("bytes.bin", "AAEC", {encoding: "base64", truncate: true}); + if (!first.success) throw new Error(first.error); + + var second = file.writeBytes("bytes.bin", "0304ff", {encoding: "hex", append: true}); + if (!second.success) throw new Error(second.error); + + var all = file.readBytes("bytes.bin", {encoding: "hex"}); + if (!all.success) throw new Error(all.error); + + var slice = file.readBytes("bytes.bin", {offset: 2, length: 2, encoding: "hex"}); + if (!slice.success) throw new Error(slice.error); + + var tail = file.readBytes("bytes.bin", {offset: 6, length: 4, encoding: "hex"}); + if (!tail.success) throw new Error(tail.error); + + return JSON.stringify({ + all: all.data, + slice: slice.data, + size: all.size, + sliceBytes: slice.bytes_read, + sliceEof: slice.eof, + tailBytes: tail.bytes_read, + tailEof: tail.eof + }); + })() + `) + if err != nil { + t.Fatalf("file byte APIs failed: %v", err) + } + + decoded := decodeJSONResult[struct { + All string `json:"all"` + Slice string `json:"slice"` + Size int64 `json:"size"` + SliceBytes int `json:"sliceBytes"` + SliceEof bool `json:"sliceEof"` + TailBytes int `json:"tailBytes"` + TailEof bool `json:"tailEof"` + }](t, result) + + if decoded.All != "0001020304ff" { + t.Fatalf("all = %q", decoded.All) + } + if decoded.Slice != "0203" { + t.Fatalf("slice = %q", decoded.Slice) + } + if decoded.Size != 6 { + t.Fatalf("size = %d", decoded.Size) + } + if decoded.SliceBytes != 2 { + t.Fatalf("slice bytes = %d", decoded.SliceBytes) + } + if decoded.SliceEof { + t.Fatal("slice should not be EOF") + } + if decoded.TailBytes != 0 || !decoded.TailEof { + t.Fatalf("tail read mismatch: bytes=%d eof=%v", decoded.TailBytes, decoded.TailEof) + } +} + +func TestExtensionRuntime_BlockCipherCBCSupportsBlowfish(t *testing.T) { + vm := newBinaryTestRuntime(t, false) + + result, err := vm.RunString(` + (function() { + var options = { + algorithm: "blowfish", + mode: "cbc", + key: "0123456789ABCDEFF0E1D2C3B4A59687", + keyEncoding: "hex", + iv: "0001020304050607", + ivEncoding: "hex", + inputEncoding: "hex", + outputEncoding: "hex", + padding: "none" + }; + var enc = utils.encryptBlockCipher("00112233445566778899aabbccddeeff", options); + if (!enc.success) throw new Error(enc.error); + var dec = utils.decryptBlockCipher(enc.data, options); + if (!dec.success) throw new Error(dec.error); + return JSON.stringify({enc: enc.data, dec: dec.data}); + })() + `) + if err != nil { + t.Fatalf("blowfish block cipher failed: %v", err) + } + + decoded := decodeJSONResult[struct { + Enc string `json:"enc"` + Dec string `json:"dec"` + }](t, result) + + if decoded.Dec != "00112233445566778899aabbccddeeff" { + t.Fatalf("dec = %q", decoded.Dec) + } + if decoded.Enc == decoded.Dec { + t.Fatal("expected ciphertext to differ from plaintext") + } +} + +func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) { + vm := newBinaryTestRuntime(t, false) + + result, err := vm.RunString(` + (function() { + var options = { + algorithm: "aes", + mode: "cbc", + key: "000102030405060708090a0b0c0d0e0f", + keyEncoding: "hex", + iv: "0f0e0d0c0b0a09080706050403020100", + ivEncoding: "hex", + inputEncoding: "utf8", + outputEncoding: "base64", + padding: "pkcs7" + }; + var enc = utils.encryptBlockCipher("hello generic cbc", options); + if (!enc.success) throw new Error(enc.error); + var dec = utils.decryptBlockCipher(enc.data, { + algorithm: "aes", + mode: "cbc", + key: options.key, + keyEncoding: options.keyEncoding, + iv: options.iv, + ivEncoding: options.ivEncoding, + inputEncoding: "base64", + outputEncoding: "utf8", + padding: "pkcs7" + }); + if (!dec.success) throw new Error(dec.error); + return dec.data; + })() + `) + if err != nil { + t.Fatalf("aes block cipher failed: %v", err) + } + + if result.String() != "hello generic cbc" { + t.Fatalf("unexpected decrypted value: %q", result.String()) + } +} diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 401f294..1ea0f9b 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -346,6 +346,104 @@ func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value { }) } +func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "path is required", + }) + } + + path := call.Arguments[0].String() + fullPath, err := r.validatePath(path) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + options := parseRuntimeOptionsArgument(call, 1) + offset := runtimeOptionInt64(options, "offset", 0) + length := runtimeOptionInt64(options, "length", -1) + encoding := runtimeOptionString(options, "encoding", "base64") + if offset < 0 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "offset must be >= 0", + }) + } + + file, err := os.Open(fullPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + size := info.Size() + if offset > size { + offset = size + } + if _, err := file.Seek(offset, io.SeekStart); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to seek file: %v", err), + }) + } + + var data []byte + switch { + case length == 0: + data = []byte{} + case length > 0: + buf := make([]byte, int(length)) + n, readErr := file.Read(buf) + if readErr != nil && readErr != io.EOF { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to read file: %v", readErr), + }) + } + data = buf[:n] + default: + data, err = io.ReadAll(file) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to read file: %v", err), + }) + } + } + + encoded, err := encodeRuntimeBytes(data, encoding) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "data": encoded, + "bytes_read": len(data), + "offset": offset, + "size": size, + "eof": offset+int64(len(data)) >= size, + }) +} + func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]interface{}{ @@ -386,6 +484,107 @@ func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { }) } +func (r *extensionRuntime) fileWriteBytes(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "path and data are required", + }) + } + + path := call.Arguments[0].String() + fullPath, err := r.validatePath(path) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + options := parseRuntimeOptionsArgument(call, 2) + appendMode := runtimeOptionBool(options, "append", false) + truncate := runtimeOptionBool(options, "truncate", false) + hasOffset := runtimeOptionHasKey(options, "offset") + offset := runtimeOptionInt64(options, "offset", 0) + encoding := runtimeOptionString(options, "encoding", "base64") + + if appendMode && hasOffset { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "append and offset cannot be used together", + }) + } + if offset < 0 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "offset must be >= 0", + }) + } + + data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to create directory: %v", err), + }) + } + + flags := os.O_CREATE | os.O_WRONLY + if appendMode { + flags |= os.O_APPEND + } + if truncate { + flags |= os.O_TRUNC + } + + file, err := os.OpenFile(fullPath, flags, 0644) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + defer file.Close() + + if hasOffset && !appendMode { + if _, err := file.Seek(offset, io.SeekStart); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to seek file: %v", err), + }) + } + } + + written, err := file.Write(data) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + info, statErr := file.Stat() + size := int64(0) + if statErr == nil { + size = info.Size() + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "path": fullPath, + "bytes_written": written, + "size": size, + }) +} + func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]interface{}{ diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 9c28852..7382595 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2391,6 +2391,297 @@ class DownloadQueueNotifier extends Notifier { ); } + String? _extractKnownDeezerTrackId(Track track) { + final deezerId = track.deezerId?.trim(); + if (deezerId != null && deezerId.isNotEmpty) { + return deezerId; + } + + if (track.id.startsWith('deezer:')) { + final rawId = track.id.substring('deezer:'.length).trim(); + if (rawId.isNotEmpty) { + return rawId; + } + } + + final availabilityDeezerId = track.availability?.deezerId?.trim(); + if (availabilityDeezerId != null && availabilityDeezerId.isNotEmpty) { + return availabilityDeezerId; + } + + return null; + } + + Future _searchDeezerTrackIdByIsrc( + String? isrc, { + required String lookupContext, + }) async { + final normalizedIsrc = normalizeOptionalString(isrc); + if (normalizedIsrc == null || !_isValidISRC(normalizedIsrc)) { + return null; + } + + try { + _log.d('No Deezer ID, searching by $lookupContext: $normalizedIsrc'); + final deezerResult = await PlatformBridge.searchDeezerByISRC( + normalizedIsrc, + ); + if (deezerResult['success'] == true && deezerResult['track_id'] != null) { + final deezerTrackId = deezerResult['track_id'].toString(); + _log.d('Found Deezer track ID via $lookupContext: $deezerTrackId'); + return deezerTrackId; + } + } catch (e) { + _log.w('Failed to search Deezer by $lookupContext: $e'); + } + + return null; + } + + Track _copyTrackWithResolvedMetadata( + Track track, { + String? resolvedIsrc, + int? trackNumber, + int? totalTracks, + int? discNumber, + int? totalDiscs, + String? releaseDate, + String? deezerId, + String? composer, + }) { + final normalizedIsrc = normalizeOptionalString(resolvedIsrc); + final normalizedComposer = normalizeOptionalString(composer); + + return Track( + id: track.id, + name: track.name, + artistName: track.artistName, + albumName: track.albumName, + albumArtist: track.albumArtist, + artistId: track.artistId, + albumId: track.albumId, + coverUrl: normalizeCoverReference(track.coverUrl), + duration: track.duration, + isrc: (normalizedIsrc != null && _isValidISRC(normalizedIsrc)) + ? normalizedIsrc + : track.isrc, + trackNumber: (track.trackNumber != null && track.trackNumber! > 0) + ? track.trackNumber + : trackNumber, + discNumber: (track.discNumber != null && track.discNumber! > 0) + ? track.discNumber + : discNumber, + totalDiscs: (track.totalDiscs != null && track.totalDiscs! > 0) + ? track.totalDiscs + : totalDiscs, + releaseDate: track.releaseDate ?? normalizeOptionalString(releaseDate), + deezerId: deezerId ?? track.deezerId, + availability: track.availability, + source: track.source, + albumType: track.albumType, + totalTracks: (track.totalTracks != null && track.totalTracks! > 0) + ? track.totalTracks + : totalTracks, + composer: (track.composer != null && track.composer!.isNotEmpty) + ? track.composer + : normalizedComposer, + itemType: track.itemType, + ); + } + + Future<_DeezerLookupPreparation> _resolveProviderTrackForDeezerLookup( + Track track, + ) async { + try { + final colonIdx = track.id.indexOf(':'); + final provider = track.id.substring(0, colonIdx); + final providerTrackId = track.id.substring(colonIdx + 1); + + _log.d('No ISRC, fetching from $provider API: $providerTrackId'); + final providerData = provider == 'tidal' + ? await PlatformBridge.getTidalMetadata('track', providerTrackId) + : await PlatformBridge.getQobuzMetadata('track', providerTrackId); + + final trackData = providerData['track'] as Map?; + if (trackData == null) { + return _DeezerLookupPreparation( + track: track, + deezerTrackId: _extractKnownDeezerTrackId(track), + ); + } + + final resolvedIsrc = normalizeOptionalString( + trackData['isrc'] as String?, + ); + if (resolvedIsrc == null || !_isValidISRC(resolvedIsrc)) { + return _DeezerLookupPreparation( + track: track, + deezerTrackId: _extractKnownDeezerTrackId(track), + ); + } + + _log.d('Resolved ISRC from $provider: $resolvedIsrc'); + + final updatedTrack = _copyTrackWithResolvedMetadata( + track, + resolvedIsrc: resolvedIsrc, + releaseDate: trackData['release_date'] as String?, + trackNumber: trackData['track_number'] as int?, + totalTracks: trackData['total_tracks'] as int?, + discNumber: trackData['disc_number'] as int?, + totalDiscs: trackData['total_discs'] as int?, + composer: trackData['composer'] as String?, + ); + final deezerTrackId = await _searchDeezerTrackIdByIsrc( + resolvedIsrc, + lookupContext: '$provider ISRC', + ); + + return _DeezerLookupPreparation( + track: deezerTrackId == null + ? updatedTrack + : _copyTrackWithResolvedMetadata( + updatedTrack, + deezerId: deezerTrackId, + ), + deezerTrackId: + deezerTrackId ?? _extractKnownDeezerTrackId(updatedTrack), + ); + } catch (e) { + _log.w('Failed to resolve ISRC from provider: $e'); + return _DeezerLookupPreparation( + track: track, + deezerTrackId: _extractKnownDeezerTrackId(track), + ); + } + } + + Future<_DeezerLookupPreparation> _resolveSpotifyTrackViaDeezer( + Track track, + ) async { + try { + var spotifyId = track.id; + if (spotifyId.startsWith('spotify:track:')) { + spotifyId = spotifyId.split(':').last; + } + _log.d('No Deezer ID, converting from Spotify via SongLink: $spotifyId'); + + final deezerData = await PlatformBridge.convertSpotifyToDeezer( + 'track', + spotifyId, + ); + final trackData = deezerData['track']; + + String? deezerTrackId; + if (trackData is Map) { + final rawId = trackData['spotify_id'] as String?; + if (rawId != null && rawId.startsWith('deezer:')) { + deezerTrackId = rawId.split(':')[1]; + _log.d('Found Deezer track ID via SongLink: $deezerTrackId'); + } else if (deezerData['id'] != null) { + deezerTrackId = deezerData['id'].toString(); + _log.d('Found Deezer track ID via SongLink (legacy): $deezerTrackId'); + } + + final deezerIsrc = normalizeOptionalString( + trackData['isrc'] as String?, + ); + final needsEnrich = + (track.releaseDate == null && + normalizeOptionalString(trackData['release_date'] as String?) != + null) || + (track.isrc == null && deezerIsrc != null) || + (!_isValidISRC(track.isrc ?? '') && deezerIsrc != null) || + ((track.trackNumber == null || track.trackNumber! <= 0) && + (trackData['track_number'] as int?) != null && + (trackData['track_number'] as int?)! > 0) || + ((track.totalTracks == null || track.totalTracks! <= 0) && + (trackData['total_tracks'] as int?) != null && + (trackData['total_tracks'] as int?)! > 0) || + ((track.discNumber == null || track.discNumber! <= 0) && + (trackData['disc_number'] as int?) != null && + (trackData['disc_number'] as int?)! > 0) || + ((track.totalDiscs == null || track.totalDiscs! <= 0) && + (trackData['total_discs'] as int?) != null && + (trackData['total_discs'] as int?)! > 0) || + ((track.composer == null || track.composer!.isEmpty) && + normalizeOptionalString(trackData['composer'] as String?) != + null) || + deezerTrackId != null; + + final updatedTrack = needsEnrich + ? _copyTrackWithResolvedMetadata( + track, + resolvedIsrc: deezerIsrc, + releaseDate: trackData['release_date'] as String?, + trackNumber: trackData['track_number'] as int?, + totalTracks: trackData['total_tracks'] as int?, + discNumber: trackData['disc_number'] as int?, + totalDiscs: trackData['total_discs'] as int?, + composer: trackData['composer'] as String?, + deezerId: deezerTrackId, + ) + : track; + + if (needsEnrich) { + _log.d( + 'Enriched track from Deezer - date: ${updatedTrack.releaseDate}, ISRC: ${updatedTrack.isrc}, track: ${updatedTrack.trackNumber}, disc: ${updatedTrack.discNumber}', + ); + } + + return _DeezerLookupPreparation( + track: updatedTrack, + deezerTrackId: + deezerTrackId ?? _extractKnownDeezerTrackId(updatedTrack), + ); + } + + if (deezerData['id'] != null) { + deezerTrackId = deezerData['id'].toString(); + _log.d('Found Deezer track ID via SongLink (flat): $deezerTrackId'); + return _DeezerLookupPreparation( + track: _copyTrackWithResolvedMetadata(track, deezerId: deezerTrackId), + deezerTrackId: deezerTrackId, + ); + } + } catch (e) { + _log.w('Failed to convert Spotify to Deezer via SongLink: $e'); + } + + return _DeezerLookupPreparation( + track: track, + deezerTrackId: _extractKnownDeezerTrackId(track), + ); + } + + Future<_DeezerExtendedMetadataFields> _loadDeezerExtendedMetadata( + String deezerTrackId, + ) async { + try { + final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata( + deezerTrackId, + ); + if (extendedMetadata == null) { + return const _DeezerExtendedMetadataFields(); + } + + final metadata = _DeezerExtendedMetadataFields( + genre: normalizeOptionalString(extendedMetadata['genre']), + label: normalizeOptionalString(extendedMetadata['label']), + copyright: normalizeOptionalString(extendedMetadata['copyright']), + ); + if (metadata.hasAnyValue) { + _log.d( + 'Extended metadata - Genre: ${metadata.genre}, Label: ${metadata.label}, Copyright: ${metadata.copyright}', + ); + } + return metadata; + } catch (e) { + _log.w('Failed to fetch extended metadata from Deezer: $e'); + return const _DeezerExtendedMetadataFields(); + } + } + String _newQueueItemId(Track track, {Set? takenIds}) { final trimmedIsrc = track.isrc?.trim(); final trimmedTrackId = track.id.trim(); @@ -4204,32 +4495,16 @@ class DownloadQueueNotifier extends Notifier { e.id.toLowerCase() == trackSource, ); - String? deezerTrackId = trackToDownload.deezerId; - if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { - deezerTrackId = trackToDownload.id.split(':')[1]; - } - if (deezerTrackId == null && - trackToDownload.availability?.deezerId != null) { - deezerTrackId = trackToDownload.availability!.deezerId; - } + String? deezerTrackId = _extractKnownDeezerTrackId(trackToDownload); if (deezerTrackId == null && trackToDownload.isrc != null && trackToDownload.isrc!.isNotEmpty && _isValidISRC(trackToDownload.isrc!)) { - try { - _log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}'); - final deezerResult = await PlatformBridge.searchDeezerByISRC( - trackToDownload.isrc!, - ); - if (deezerResult['success'] == true && - deezerResult['track_id'] != null) { - deezerTrackId = deezerResult['track_id'].toString(); - _log.d('Found Deezer track ID via ISRC: $deezerTrackId'); - } - } catch (e) { - _log.w('Failed to search Deezer by ISRC: $e'); - } + deezerTrackId = await _searchDeezerTrackIdByIsrc( + trackToDownload.isrc, + lookupContext: 'ISRC', + ); if (shouldAbortWork('during Deezer ISRC lookup')) { return; @@ -4244,94 +4519,11 @@ class DownloadQueueNotifier extends Notifier { !_isValidISRC(trackToDownload.isrc!)) && (trackToDownload.id.startsWith('tidal:') || trackToDownload.id.startsWith('qobuz:'))) { - try { - final colonIdx = trackToDownload.id.indexOf(':'); - final provider = trackToDownload.id.substring(0, colonIdx); - final providerTrackId = trackToDownload.id.substring(colonIdx + 1); - - _log.d('No ISRC, fetching from $provider API: $providerTrackId'); - final providerData = provider == 'tidal' - ? await PlatformBridge.getTidalMetadata('track', providerTrackId) - : await PlatformBridge.getQobuzMetadata('track', providerTrackId); - - final trackData = providerData['track'] as Map?; - if (trackData != null) { - final resolvedIsrc = normalizeOptionalString( - trackData['isrc'] as String?, - ); - - if (resolvedIsrc != null && _isValidISRC(resolvedIsrc)) { - _log.d('Resolved ISRC from $provider: $resolvedIsrc'); - - final provReleaseDate = normalizeOptionalString( - trackData['release_date'] as String?, - ); - final provTrackNum = trackData['track_number'] as int?; - final provTotalTracks = trackData['total_tracks'] as int?; - final provDiscNum = trackData['disc_number'] as int?; - final provTotalDiscs = trackData['total_discs'] as int?; - final provComposer = normalizeOptionalString( - trackData['composer'] as String?, - ); - - trackToDownload = Track( - id: trackToDownload.id, - name: trackToDownload.name, - artistName: trackToDownload.artistName, - albumName: trackToDownload.albumName, - albumArtist: trackToDownload.albumArtist, - artistId: trackToDownload.artistId, - albumId: trackToDownload.albumId, - coverUrl: normalizeCoverReference(trackToDownload.coverUrl), - duration: trackToDownload.duration, - isrc: resolvedIsrc, - trackNumber: - (trackToDownload.trackNumber != null && - trackToDownload.trackNumber! > 0) - ? trackToDownload.trackNumber - : provTrackNum, - discNumber: - (trackToDownload.discNumber != null && - trackToDownload.discNumber! > 0) - ? trackToDownload.discNumber - : provDiscNum, - totalDiscs: - (trackToDownload.totalDiscs != null && - trackToDownload.totalDiscs! > 0) - ? trackToDownload.totalDiscs - : provTotalDiscs, - releaseDate: trackToDownload.releaseDate ?? provReleaseDate, - deezerId: trackToDownload.deezerId, - availability: trackToDownload.availability, - albumType: trackToDownload.albumType, - totalTracks: - (trackToDownload.totalTracks != null && - trackToDownload.totalTracks! > 0) - ? trackToDownload.totalTracks - : provTotalTracks, - composer: trackToDownload.composer ?? provComposer, - source: trackToDownload.source, - ); - - try { - final deezerResult = await PlatformBridge.searchDeezerByISRC( - resolvedIsrc, - ); - if (deezerResult['success'] == true && - deezerResult['track_id'] != null) { - deezerTrackId = deezerResult['track_id'].toString(); - _log.d( - 'Found Deezer track ID via $provider ISRC: $deezerTrackId', - ); - } - } catch (e) { - _log.w('Failed to search Deezer by $provider ISRC: $e'); - } - } - } - } catch (e) { - _log.w('Failed to resolve ISRC from provider: $e'); - } + final providerLookup = await _resolveProviderTrackForDeezerLookup( + trackToDownload, + ); + trackToDownload = providerLookup.track; + deezerTrackId ??= providerLookup.deezerTrackId; if (shouldAbortWork('during provider ISRC resolution')) { return; @@ -4346,124 +4538,11 @@ class DownloadQueueNotifier extends Notifier { !trackToDownload.id.startsWith('extension:') && !trackToDownload.id.startsWith('tidal:') && !trackToDownload.id.startsWith('qobuz:')) { - try { - String spotifyId = trackToDownload.id; - if (spotifyId.startsWith('spotify:track:')) { - spotifyId = spotifyId.split(':').last; - } - _log.d( - 'No Deezer ID, converting from Spotify via SongLink: $spotifyId', - ); - final deezerData = await PlatformBridge.convertSpotifyToDeezer( - 'track', - spotifyId, - ); - final trackData = deezerData['track']; - if (trackData is Map) { - final rawId = trackData['spotify_id'] as String?; - if (rawId != null && rawId.startsWith('deezer:')) { - deezerTrackId = rawId.split(':')[1]; - _log.d('Found Deezer track ID via SongLink: $deezerTrackId'); - } else if (deezerData['id'] != null) { - deezerTrackId = deezerData['id'].toString(); - _log.d( - 'Found Deezer track ID via SongLink (legacy): $deezerTrackId', - ); - } - - // Enrich track metadata from Deezer response (release_date, isrc, etc.) - final deezerReleaseDate = normalizeOptionalString( - trackData['release_date'] as String?, - ); - final deezerIsrc = normalizeOptionalString( - trackData['isrc'] as String?, - ); - final deezerTrackNum = trackData['track_number'] as int?; - final deezerTotalTracks = trackData['total_tracks'] as int?; - final deezerDiscNum = trackData['disc_number'] as int?; - final deezerTotalDiscs = trackData['total_discs'] as int?; - final deezerComposer = normalizeOptionalString( - trackData['composer'] as String?, - ); - - final needsEnrich = - (trackToDownload.releaseDate == null && - deezerReleaseDate != null) || - (trackToDownload.isrc == null && deezerIsrc != null) || - (!_isValidISRC(trackToDownload.isrc ?? '') && - deezerIsrc != null) || - ((trackToDownload.trackNumber == null || - trackToDownload.trackNumber! <= 0) && - deezerTrackNum != null && - deezerTrackNum > 0) || - ((trackToDownload.totalTracks == null || - trackToDownload.totalTracks! <= 0) && - deezerTotalTracks != null && - deezerTotalTracks > 0) || - ((trackToDownload.discNumber == null || - trackToDownload.discNumber! <= 0) && - deezerDiscNum != null && - deezerDiscNum > 0) || - ((trackToDownload.totalDiscs == null || - trackToDownload.totalDiscs! <= 0) && - deezerTotalDiscs != null && - deezerTotalDiscs > 0) || - ((trackToDownload.composer == null || - trackToDownload.composer!.isEmpty) && - deezerComposer != null); - - if (needsEnrich) { - trackToDownload = Track( - id: trackToDownload.id, - name: trackToDownload.name, - artistName: trackToDownload.artistName, - albumName: trackToDownload.albumName, - albumArtist: trackToDownload.albumArtist, - artistId: trackToDownload.artistId, - albumId: trackToDownload.albumId, - coverUrl: normalizeCoverReference(trackToDownload.coverUrl), - duration: trackToDownload.duration, - isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc)) - ? deezerIsrc - : trackToDownload.isrc, - trackNumber: - (trackToDownload.trackNumber != null && - trackToDownload.trackNumber! > 0) - ? trackToDownload.trackNumber - : deezerTrackNum, - discNumber: - (trackToDownload.discNumber != null && - trackToDownload.discNumber! > 0) - ? trackToDownload.discNumber - : deezerDiscNum, - totalDiscs: - (trackToDownload.totalDiscs != null && - trackToDownload.totalDiscs! > 0) - ? trackToDownload.totalDiscs - : deezerTotalDiscs, - releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate, - deezerId: deezerTrackId, - availability: trackToDownload.availability, - albumType: trackToDownload.albumType, - totalTracks: - (trackToDownload.totalTracks != null && - trackToDownload.totalTracks! > 0) - ? trackToDownload.totalTracks - : deezerTotalTracks, - composer: trackToDownload.composer ?? deezerComposer, - source: trackToDownload.source, - ); - _log.d( - 'Enriched track from Deezer - date: ${trackToDownload.releaseDate}, ISRC: ${trackToDownload.isrc}, track: ${trackToDownload.trackNumber}, disc: ${trackToDownload.discNumber}', - ); - } - } else if (deezerData['id'] != null) { - deezerTrackId = deezerData['id'].toString(); - _log.d('Found Deezer track ID via SongLink (flat): $deezerTrackId'); - } - } catch (e) { - _log.w('Failed to convert Spotify to Deezer via SongLink: $e'); - } + final spotifyLookup = await _resolveSpotifyTrackViaDeezer( + trackToDownload, + ); + trackToDownload = spotifyLookup.track; + deezerTrackId ??= spotifyLookup.deezerTrackId; if (shouldAbortWork('during SongLink availability lookup')) { return; @@ -4480,22 +4559,12 @@ class DownloadQueueNotifier extends Notifier { } if (deezerTrackId != null && deezerTrackId.isNotEmpty) { - try { - final extendedMetadata = - await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId); - if (extendedMetadata != null) { - genre = extendedMetadata['genre']; - label = extendedMetadata['label']; - copyright = extendedMetadata['copyright']; - if (genre != null && genre.isNotEmpty) { - _log.d( - 'Extended metadata - Genre: $genre, Label: $label, Copyright: $copyright', - ); - } - } - } catch (e) { - _log.w('Failed to fetch extended metadata from Deezer: $e'); - } + final extendedMetadata = await _loadDeezerExtendedMetadata( + deezerTrackId, + ); + genre = extendedMetadata.genre; + label = extendedMetadata.label; + copyright = extendedMetadata.copyright; if (shouldAbortWork('during extended metadata lookup')) { return; @@ -4726,8 +4795,8 @@ class DownloadQueueNotifier extends Notifier { final actualService = ((result['service'] as String?)?.toLowerCase()) ?? item.service.toLowerCase(); - final decryptionKey = - (result['decryption_key'] as String?)?.trim() ?? ''; + final decryptionDescriptor = + DownloadDecryptionDescriptor.fromDownloadResult(result); trackToDownload = _buildTrackForMetadataEmbedding( trackToDownload, result, @@ -4737,8 +4806,10 @@ class DownloadQueueNotifier extends Notifier { 'Track coverUrl after download result: ${trackToDownload.coverUrl}', ); - if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) { - _log.i('Encrypted stream detected, decrypting via FFmpeg...'); + if (!wasExisting && decryptionDescriptor != null && filePath != null) { + _log.i( + 'Encrypted stream detected, decrypting via ${decryptionDescriptor.normalizedStrategy}...', + ); updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.9); if (effectiveSafMode && isContentUri(filePath)) { @@ -4757,9 +4828,9 @@ class DownloadQueueNotifier extends Notifier { String? decryptedTempPath; try { - decryptedTempPath = await FFmpegService.decryptAudioFile( + decryptedTempPath = await FFmpegService.decryptWithDescriptor( inputPath: tempPath, - decryptionKey: decryptionKey, + descriptor: decryptionDescriptor, deleteOriginal: false, ); if (decryptedTempPath == null) { @@ -4819,9 +4890,9 @@ class DownloadQueueNotifier extends Notifier { } } } else { - final decryptedPath = await FFmpegService.decryptAudioFile( + final decryptedPath = await FFmpegService.decryptWithDescriptor( inputPath: filePath, - decryptionKey: decryptionKey, + descriptor: decryptionDescriptor, deleteOriginal: true, ); if (decryptedPath == null) { @@ -5322,7 +5393,7 @@ class DownloadQueueNotifier extends Notifier { !effectiveSafMode && isFlacFile && !wasExisting && - decryptionKey.isNotEmpty) { + decryptionDescriptor != null) { _log.d( 'Local FLAC after decrypt detected, embedding metadata and cover...', ); @@ -5893,3 +5964,23 @@ class _AlbumRgTrackEntry { class _AlbumRgAccumulator { final List<_AlbumRgTrackEntry> entries = []; } + +class _DeezerLookupPreparation { + final Track track; + final String? deezerTrackId; + + const _DeezerLookupPreparation({required this.track, this.deezerTrackId}); +} + +class _DeezerExtendedMetadataFields { + final String? genre; + final String? label; + final String? copyright; + + const _DeezerExtendedMetadataFields({this.genre, this.label, this.copyright}); + + bool get hasAnyValue => + (genre != null && genre!.isNotEmpty) || + (label != null && label!.isNotEmpty) || + (copyright != null && copyright!.isNotEmpty); +} diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 81bce27..f6da1bb 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -820,7 +820,7 @@ class ExtensionNotifier extends Notifier { } } - for (final provider in const ['tidal', 'qobuz', 'deezer']) { + for (final provider in const ['tidal', 'qobuz']) { if (!result.contains(provider)) { result.add(provider); } @@ -896,7 +896,7 @@ class ExtensionNotifier extends Notifier { } List getAllDownloadProviders() { - final providers = ['tidal', 'qobuz', 'deezer']; + final providers = ['tidal', 'qobuz']; for (final ext in state.extensions) { if (ext.enabled && ext.hasDownloadProvider) { providers.add(ext.id); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index f9da22c..14df2d6 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -12,7 +12,7 @@ import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; -const _currentMigrationVersion = 9; +const _currentMigrationVersion = 10; const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); @@ -132,8 +132,9 @@ class SettingsNotifier extends Notifier { ); } state = state.copyWith(lastSeenVersion: AppInfo.version); - // Migration 7: YouTube is no longer a built-in service — reset to Tidal - if (state.defaultService == 'youtube') { + // Migration 7/10: retired built-in services reset back to Tidal + if (state.defaultService == 'youtube' || + state.defaultService == 'deezer') { state = state.copyWith(defaultService: 'tidal'); } await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index fdde453..07ece41 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -371,7 +371,6 @@ class _AlbumScreenState extends ConsumerState { } if (widget.albumId.startsWith('tidal:')) return 'tidal'; if (widget.albumId.startsWith('qobuz:')) return 'qobuz'; - if (widget.albumId.startsWith('deezer:')) return 'deezer'; return null; } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 165c4b0..2daba94 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -159,7 +159,6 @@ class _ArtistScreenState extends ConsumerState { } if (widget.artistId.startsWith('tidal:')) return 'tidal'; if (widget.artistId.startsWith('qobuz:')) return 'qobuz'; - if (widget.artistId.startsWith('deezer:')) return 'deezer'; return null; } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index d0fc5fb..bdec083 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -61,7 +61,6 @@ class _PlaylistScreenState extends ConsumerState { if (playlistId != null) { if (playlistId.startsWith('tidal:')) return 'tidal'; if (playlistId.startsWith('qobuz:')) return 'qobuz'; - if (playlistId.startsWith('deezer:')) return 'deezer'; } final source = _tracks.firstOrNull?.source; @@ -72,7 +71,6 @@ class _PlaylistScreenState extends ConsumerState { final trackId = _tracks.firstOrNull?.id ?? ''; if (trackId.startsWith('tidal:')) return 'tidal'; if (trackId.startsWith('qobuz:')) return 'qobuz'; - if (trackId.startsWith('deezer:')) return 'deezer'; return null; } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index e420417..543dbc1 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -24,7 +24,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { } class _DownloadSettingsPageState extends ConsumerState { - static const _builtInServices = ['tidal', 'qobuz', 'deezer']; + static const _builtInServices = ['tidal', 'qobuz']; static const _songLinkRegions = [ 'AD', 'AE', @@ -2053,7 +2053,7 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); - final builtInServiceIds = ['tidal', 'qobuz', 'deezer']; + final builtInServiceIds = ['tidal', 'qobuz']; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 8160a1c..0d08b26 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -13,6 +13,95 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('FFmpeg'); +class DownloadDecryptionDescriptor { + final String strategy; + final String key; + final String? iv; + final String? inputFormat; + final String? outputExtension; + final Map options; + + const DownloadDecryptionDescriptor({ + required this.strategy, + required this.key, + this.iv, + this.inputFormat, + this.outputExtension, + this.options = const {}, + }); + + factory DownloadDecryptionDescriptor.fromJson(Map json) { + final rawOptions = json['options']; + return DownloadDecryptionDescriptor( + strategy: (json['strategy'] as String? ?? '').trim(), + key: (json['key'] as String? ?? '').trim(), + iv: (json['iv'] as String?)?.trim(), + inputFormat: (json['input_format'] as String?)?.trim(), + outputExtension: (json['output_extension'] as String?)?.trim(), + options: rawOptions is Map + ? Map.from(rawOptions) + : const {}, + ); + } + + Map toJson() { + final json = {'strategy': strategy, 'key': key}; + if (iv != null && iv!.isNotEmpty) { + json['iv'] = iv; + } + if (inputFormat != null && inputFormat!.isNotEmpty) { + json['input_format'] = inputFormat; + } + if (outputExtension != null && outputExtension!.isNotEmpty) { + json['output_extension'] = outputExtension; + } + if (options.isNotEmpty) { + json['options'] = options; + } + return json; + } + + static DownloadDecryptionDescriptor? fromDownloadResult( + Map result, + ) { + final rawDecryption = result['decryption']; + if (rawDecryption is Map) { + final descriptor = DownloadDecryptionDescriptor.fromJson( + Map.from(rawDecryption), + ); + if (descriptor.normalizedStrategy == 'ffmpeg.mov_key' && + descriptor.key.isNotEmpty) { + return descriptor; + } + } + + final legacyKey = (result['decryption_key'] as String?)?.trim() ?? ''; + if (legacyKey.isEmpty) { + return null; + } + + return DownloadDecryptionDescriptor( + strategy: 'ffmpeg.mov_key', + key: legacyKey, + inputFormat: 'mov', + ); + } + + String get normalizedStrategy { + switch (strategy.trim().toLowerCase()) { + case '': + case 'ffmpeg.mov_key': + case 'ffmpeg_mov_key': + case 'mov_decryption_key': + case 'mp4_decryption_key': + case 'ffmpeg.mp4_decryption_key': + return 'ffmpeg.mov_key'; + default: + return strategy.trim(); + } + } +} + class FFmpegService { static const int _commandLogPreviewLength = 300; static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8); @@ -22,6 +111,7 @@ class FFmpegService { static const Duration _liveTunnelStabilizationDelay = Duration( milliseconds: 900, ); + static const String _genericMovKeyDecryptionStrategy = 'ffmpeg.mov_key'; static int _tempEmbedCounter = 0; static FFmpegSession? _activeLiveDecryptSession; static String? _activeLiveDecryptUrl; @@ -216,12 +306,56 @@ class FFmpegService { required String decryptionKey, bool deleteOriginal = true, }) async { - final trimmedKey = decryptionKey.trim(); - if (trimmedKey.isEmpty) return inputPath; + return decryptWithDescriptor( + inputPath: inputPath, + descriptor: DownloadDecryptionDescriptor( + strategy: _genericMovKeyDecryptionStrategy, + key: decryptionKey, + inputFormat: 'mov', + ), + deleteOriginal: deleteOriginal, + ); + } - // Encrypted streams are commonly MP4 container with FLAC audio. - // Prefer FLAC output to avoid MP4 muxing errors during decrypt copy. - final preferredExt = inputPath.toLowerCase().endsWith('.m4a') + static Future decryptWithDescriptor({ + required String inputPath, + required DownloadDecryptionDescriptor descriptor, + bool deleteOriginal = true, + }) async { + final key = descriptor.key.trim(); + + switch (descriptor.normalizedStrategy) { + case _genericMovKeyDecryptionStrategy: + if (key.isEmpty) { + return inputPath; + } + return _decryptMovKeyFile( + inputPath: inputPath, + decryptionKey: key, + inputFormat: descriptor.inputFormat, + outputExtension: descriptor.outputExtension, + deleteOriginal: deleteOriginal, + ); + default: + _log.e( + 'Unsupported download decryption strategy: ${descriptor.strategy}', + ); + return null; + } + } + + static String _resolvePreferredDecryptionExtension( + String inputPath, + String? requestedExtension, + ) { + final trimmedRequested = (requestedExtension ?? '').trim(); + if (trimmedRequested.isNotEmpty) { + return trimmedRequested.startsWith('.') + ? trimmedRequested + : '.$trimmedRequested'; + } + + return inputPath.toLowerCase().endsWith('.m4a') ? '.flac' : inputPath.toLowerCase().endsWith('.flac') ? '.flac' @@ -230,7 +364,23 @@ class FFmpegService { : inputPath.toLowerCase().endsWith('.opus') ? '.opus' : '.flac'; + } + + static Future _decryptMovKeyFile({ + required String inputPath, + required String decryptionKey, + String? inputFormat, + String? outputExtension, + bool deleteOriginal = true, + }) async { + final preferredExt = _resolvePreferredDecryptionExtension( + inputPath, + outputExtension, + ); var tempOutput = _buildOutputPath(inputPath, preferredExt); + final demuxerFormat = (inputFormat ?? '').trim().isNotEmpty + ? inputFormat!.trim() + : 'mov'; String buildDecryptCommand( String outputPath, { @@ -241,10 +391,10 @@ class FFmpegService { // Force MOV demuxer: -decryption_key is only supported by the MOV/MP4 // demuxer. The input may carry a .flac extension (SAF mode) while actually // containing an encrypted M4A stream, so we must override auto-detection. - return '-v error -decryption_key "$key" -f mov -i "$inputPath" $audioMap-c copy "$outputPath" -y'; + return '-v error -decryption_key "$key" -f $demuxerFormat -i "$inputPath" $audioMap-c copy "$outputPath" -y'; } - final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey); + final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey); if (keyCandidates.isEmpty) { _log.e('No usable decryption key candidates'); return null; diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 5845d64..e06f5a7 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -64,17 +64,6 @@ const _builtInServices = [ ), ], ), - BuiltInService( - id: 'deezer', - label: 'Deezer', - qualityOptions: [ - QualityOption( - id: 'FLAC', - label: 'FLAC Best Quality', - description: 'Up to 24-bit / 48kHz+', - ), - ], - ), ]; class DownloadServicePicker extends ConsumerStatefulWidget { @@ -138,6 +127,18 @@ class _DownloadServicePickerState extends ConsumerState { } else { _selectedService = ref.read(settingsProvider).defaultService; } + if (!_builtInServices.any((service) => service.id == _selectedService)) { + final extensionState = ref.read(extensionProvider); + final hasMatchingExtension = extensionState.extensions.any( + (ext) => + ext.enabled && + ext.hasDownloadProvider && + ext.id == _selectedService, + ); + if (!hasMatchingExtension) { + _selectedService = 'tidal'; + } + } } List _getQualityOptions() {