mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-04-21 11:06:25 +02:00
refactor: move deezer to extension
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+34
-69
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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{}{
|
||||
|
||||
@@ -2391,6 +2391,297 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
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<String?> _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<String, dynamic>?;
|
||||
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<String, dynamic>) {
|
||||
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<String>? takenIds}) {
|
||||
final trimmedIsrc = track.isrc?.trim();
|
||||
final trimmedTrackId = track.id.trim();
|
||||
@@ -4204,32 +4495,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
!_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<String, dynamic>?;
|
||||
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<DownloadQueueState> {
|
||||
!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<String, dynamic>) {
|
||||
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<DownloadQueueState> {
|
||||
}
|
||||
|
||||
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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
'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<DownloadQueueState> {
|
||||
|
||||
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<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
} 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<DownloadQueueState> {
|
||||
!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);
|
||||
}
|
||||
|
||||
@@ -820,7 +820,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
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<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> 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);
|
||||
|
||||
@@ -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<AppSettings> {
|
||||
);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -371,7 +371,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
if (widget.albumId.startsWith('tidal:')) return 'tidal';
|
||||
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.albumId.startsWith('deezer:')) return 'deezer';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
||||
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.artistId.startsWith('deezer:')) return 'deezer';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
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<PlaylistScreen> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
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)
|
||||
|
||||
@@ -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<String, dynamic> options;
|
||||
|
||||
const DownloadDecryptionDescriptor({
|
||||
required this.strategy,
|
||||
required this.key,
|
||||
this.iv,
|
||||
this.inputFormat,
|
||||
this.outputExtension,
|
||||
this.options = const {},
|
||||
});
|
||||
|
||||
factory DownloadDecryptionDescriptor.fromJson(Map<String, dynamic> 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<String, dynamic>.from(rawOptions)
|
||||
: const {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{'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<String, dynamic> result,
|
||||
) {
|
||||
final rawDecryption = result['decryption'];
|
||||
if (rawDecryption is Map) {
|
||||
final descriptor = DownloadDecryptionDescriptor.fromJson(
|
||||
Map<String, dynamic>.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<String?> 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<String?> _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;
|
||||
|
||||
@@ -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<DownloadServicePicker> {
|
||||
} 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<QualityOption> _getQualityOptions() {
|
||||
|
||||
Reference in New Issue
Block a user