Compare commits

...

7 Commits

Author SHA1 Message Date
zarzet 8615cde898 chore: bump app to v4.2.2 2026-04-06 14:21:54 +07:00
zarzet 207c0653cc refactor: move deezer to extension 2026-04-06 14:15:44 +07:00
zarzet de756e5d86 fix: preserve flat singles output for extension releases 2026-04-06 04:27:37 +07:00
zarzet fd5db3f7b6 fix: align re-enrich matching with autofill metadata 2026-04-06 03:39:35 +07:00
zarzet d087da9409 fix: persist downloaded metadata and refine metadata navigation 2026-04-06 03:20:04 +07:00
zarzet 43469a7ef2 feat: add configurable extension download fallback 2026-04-06 03:00:17 +07:00
zarzet add4af831e fix: preserve composer metadata across qobuz and history 2026-04-06 01:58:36 +07:00
52 changed files with 3385 additions and 1035 deletions
@@ -944,16 +944,19 @@ class MainActivity: FlutterFragmentActivity() {
) { ) {
try { try {
val srcFile = java.io.File(goFilePath) val srcFile = java.io.File(goFilePath)
if (srcFile.exists() && srcFile.length() > 0) { if (!srcFile.exists() || srcFile.length() <= 0) {
contentResolver.openOutputStream(document.uri, "wt")?.use { output -> throw IllegalStateException("extension output missing or empty: $goFilePath")
srcFile.inputStream().use { input ->
input.copyTo(output)
}
}
srcFile.delete()
} }
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) { } catch (e: Exception) {
document.delete()
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}") 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()) respObj.put("file_path", document.uri.toString())
@@ -2965,6 +2968,13 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
"setDownloadFallbackExtensionIds" -> {
val extensionIdsJson = call.argument<String>("extension_ids") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setExtensionFallbackProviderIDsJSON(extensionIdsJson)
}
result.success(null)
}
"setMetadataProviderPriority" -> { "setMetadataProviderPriority" -> {
val priorityJson = call.argument<String>("priority") ?: "[]" val priorityJson = call.argument<String>("priority") ?: "[]"
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
-446
View File
@@ -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
}
+114 -82
View File
@@ -74,33 +74,34 @@ type DownloadRequest struct {
} }
type DownloadResponse struct { type DownloadResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message"` Message string `json:"message"`
FilePath string `json:"file_path,omitempty"` FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"` ErrorType string `json:"error_type,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"` AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"` ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"` ActualSampleRate int `json:"actual_sample_rate,omitempty"`
Service string `json:"service,omitempty"` Service string `json:"service,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"` Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"` Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"` AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"` TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"` TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"` TotalDiscs int `json:"total_discs,omitempty"`
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
Genre string `json:"genre,omitempty"` Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"` Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"` Composer string `json:"composer,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"` LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"` DecryptionKey string `json:"decryption_key,omitempty"`
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
} }
type DownloadResult struct { type DownloadResult struct {
@@ -123,6 +124,7 @@ type DownloadResult struct {
Composer string Composer string
LyricsLRC string LyricsLRC string
DecryptionKey string DecryptionKey string
Decryption *DownloadDecryptionInfo
} }
type reEnrichRequest struct { type reEnrichRequest struct {
@@ -183,6 +185,12 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
} }
if req.shouldUpdateField("basic_tags") { if req.shouldUpdateField("basic_tags") {
if track.Name != "" {
req.TrackName = track.Name
}
if track.Artists != "" {
req.ArtistName = track.Artists
}
if track.AlbumName != "" { if track.AlbumName != "" {
req.AlbumName = track.AlbumName req.AlbumName = track.AlbumName
} }
@@ -236,6 +244,29 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
} }
} }
func isPlaceholderReEnrichValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "unknown", "unknown artist", "unknown title", "unknown album":
return true
default:
return false
}
}
func buildReEnrichSearchQuery(req reEnrichRequest) string {
parts := make([]string, 0, 2)
if !isPlaceholderReEnrichValue(req.TrackName) {
parts = append(parts, strings.TrimSpace(req.TrackName))
}
if !isPlaceholderReEnrichValue(req.ArtistName) {
parts = append(parts, strings.TrimSpace(req.ArtistName))
}
if len(parts) == 0 && !isPlaceholderReEnrichValue(req.AlbumName) {
parts = append(parts, strings.TrimSpace(req.AlbumName))
}
return strings.TrimSpace(strings.Join(parts, " "))
}
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest { func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
return DownloadRequest{ return DownloadRequest{
TrackName: req.TrackName, TrackName: req.TrackName,
@@ -256,6 +287,12 @@ func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string { func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string {
metadata := map[string]string{} metadata := map[string]string{}
if req.shouldUpdateField("basic_tags") { if req.shouldUpdateField("basic_tags") {
if req.TrackName != "" {
metadata["TITLE"] = req.TrackName
}
if req.ArtistName != "" {
metadata["ARTIST"] = req.ArtistName
}
if req.AlbumName != "" { if req.AlbumName != "" {
metadata["ALBUM"] = req.AlbumName metadata["ALBUM"] = req.AlbumName
} }
@@ -599,6 +636,7 @@ func buildDownloadSuccessResponse(
Composer: composer, Composer: composer,
LyricsLRC: result.LyricsLRC, LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey, DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
} }
} }
@@ -746,24 +784,6 @@ func DownloadTrack(requestJSON string) (string, error) {
} }
} }
err = 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,
}
}
err = deezerErr
default: default:
return errorResponse("Unknown service: " + req.Service) return errorResponse("Unknown service: " + req.Service)
} }
@@ -814,7 +834,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
serviceNormalized := strings.ToLower(serviceRaw) serviceNormalized := strings.ToLower(serviceRaw)
normalizedReq := req normalizedReq := req
if isBuiltInProvider(serviceNormalized) { if isBuiltInDownloadProvider(serviceNormalized) {
normalizedReq.Service = serviceNormalized normalizedReq.Service = serviceNormalized
} }
@@ -827,7 +847,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
if req.UseExtensions { if req.UseExtensions {
// Respect strict mode when auto fallback is disabled: // Respect strict mode when auto fallback is disabled:
// for built-in providers, route directly to selected service only. // for built-in providers, route directly to selected service only.
if !req.UseFallback && isBuiltInProvider(serviceNormalized) { if !req.UseFallback && isBuiltInDownloadProvider(serviceNormalized) {
return DownloadTrack(normalizedJSON) return DownloadTrack(normalizedJSON)
} }
resp, err := DownloadWithExtensionsJSON(normalizedJSON) resp, err := DownloadWithExtensionsJSON(normalizedJSON)
@@ -866,9 +886,9 @@ func DownloadWithFallback(requestJSON string) (string, error) {
enrichRequestExtendedMetadata(&req) enrichRequestExtendedMetadata(&req)
allServices := []string{"tidal", "qobuz", "deezer"} allServices := []string{"tidal", "qobuz"}
preferredService := req.Service preferredService := req.Service
if preferredService == "" { if !isBuiltInDownloadProvider(preferredService) {
preferredService = "tidal" preferredService = "tidal"
} }
@@ -934,26 +954,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
} }
err = 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) { if err != nil && errors.Is(err, ErrDownloadCancelled) {
@@ -2295,9 +2295,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
// When search_online is true, search for metadata from internet using the // When search_online is true, search for metadata from internet using the
// configured metadata-provider priority. // configured metadata-provider priority.
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" { if req.SearchOnline {
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
searchQuery := req.TrackName + " " + req.ArtistName
found := false found := false
deezerClient := GetDeezerClient() deezerClient := GetDeezerClient()
@@ -2310,17 +2308,23 @@ func ReEnrichFile(requestJSON string) (string, error) {
found = true found = true
} }
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true) searchQuery := buildReEnrichSearchQuery(req)
if searchErr == nil && len(tracks) > 0 { if searchQuery != "" {
track := selectBestReEnrichTrack(req, tracks) GoLog("[ReEnrich] Searching online metadata for query: %s\n", searchQuery)
if track != nil { tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n", if searchErr == nil && len(tracks) > 0 {
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate) track := selectBestReEnrichTrack(req, tracks)
applyReEnrichTrackMetadata(&req, *track) if track != nil {
found = true GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
applyReEnrichTrackMetadata(&req, *track)
found = true
}
} else if searchErr != nil {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
} }
} else if searchErr != nil { } else {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr) GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n")
} }
// Try to get extended metadata from Deezer if not already set // Try to get extended metadata from Deezer if not already set
@@ -2439,6 +2443,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
"duration_ms": req.DurationMs, "duration_ms": req.DurationMs,
} }
if req.shouldUpdateField("basic_tags") { if req.shouldUpdateField("basic_tags") {
enrichedMeta["track_name"] = req.TrackName
enrichedMeta["artist_name"] = req.ArtistName
enrichedMeta["album_name"] = req.AlbumName enrichedMeta["album_name"] = req.AlbumName
enrichedMeta["album_artist"] = req.AlbumArtist enrichedMeta["album_artist"] = req.AlbumArtist
} }
@@ -2471,6 +2477,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
ArtistTagMode: req.ArtistTagMode, ArtistTagMode: req.ArtistTagMode,
} }
if req.shouldUpdateField("basic_tags") { if req.shouldUpdateField("basic_tags") {
metadata.Title = req.TrackName
metadata.Artist = req.ArtistName
metadata.Album = req.AlbumName metadata.Album = req.AlbumName
metadata.AlbumArtist = req.AlbumArtist metadata.AlbumArtist = req.AlbumArtist
} }
@@ -2665,6 +2673,30 @@ func GetProviderPriorityJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
func SetExtensionFallbackProviderIDsJSON(providerIDsJSON string) error {
if strings.TrimSpace(providerIDsJSON) == "" {
SetExtensionFallbackProviderIDs(nil)
return nil
}
var providerIDs []string
if err := json.Unmarshal([]byte(providerIDsJSON), &providerIDs); err != nil {
return err
}
SetExtensionFallbackProviderIDs(providerIDs)
return nil
}
func GetExtensionFallbackProviderIDsJSON() (string, error) {
providerIDs := GetExtensionFallbackProviderIDs()
jsonBytes, err := json.Marshal(providerIDs)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SetMetadataProviderPriorityJSON(priorityJSON string) error { func SetMetadataProviderPriorityJSON(priorityJSON string) error {
var priority []string var priority []string
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
+79 -6
View File
@@ -2,6 +2,21 @@ package gobackend
import "testing" import "testing"
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
}
if got := GetExtensionFallbackProviderIDs(); got != nil {
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
}
}
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) { func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
req := DownloadRequest{ req := DownloadRequest{
TrackName: "Bonus Track", TrackName: "Bonus Track",
@@ -114,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) { func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
req := reEnrichRequest{ req := reEnrichRequest{
SpotifyID: "spotify-track-id", SpotifyID: "spotify-track-id",
@@ -195,13 +242,11 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
metadata := buildReEnrichFFmpegMetadata(&req, "") metadata := buildReEnrichFFmpegMetadata(&req, "")
// Title and Artist are never written by re-enrich (they are search keys if metadata["TITLE"] != "Song" {
// preserved as-is from the file). t.Fatalf("title = %q", metadata["TITLE"])
if _, exists := metadata["TITLE"]; exists {
t.Fatalf("TITLE should not be in metadata: %#v", metadata)
} }
if _, exists := metadata["ARTIST"]; exists { if metadata["ARTIST"] != "Artist" {
t.Fatalf("ARTIST should not be in metadata: %#v", metadata) t.Fatalf("artist = %q", metadata["ARTIST"])
} }
if metadata["ALBUM"] != "Album" { if metadata["ALBUM"] != "Album" {
t.Fatalf("album = %q", metadata["ALBUM"]) t.Fatalf("album = %q", metadata["ALBUM"])
@@ -225,10 +270,35 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
} }
} }
func TestBuildReEnrichSearchQuerySkipsPlaceholderArtist(t *testing.T) {
req := reEnrichRequest{
TrackName: "Sign of the Times",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
}
query := buildReEnrichSearchQuery(req)
if query != "Sign of the Times" {
t.Fatalf("query = %q", query)
}
req = reEnrichRequest{
TrackName: "Unknown Title",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
}
query = buildReEnrichSearchQuery(req)
if query != "Harry Styles" {
t.Fatalf("fallback album query = %q", query)
}
}
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) { func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
req := reEnrichRequest{} req := reEnrichRequest{}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{ applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
Name: "Resolved Song",
Artists: "Resolved Artist",
TrackNumber: 7, TrackNumber: 7,
TotalTracks: 12, TotalTracks: 12,
DiscNumber: 2, DiscNumber: 2,
@@ -242,6 +312,9 @@ func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
if req.DiscNumber != 2 || req.TotalDiscs != 3 { if req.DiscNumber != 2 || req.TotalDiscs != 3 {
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs) t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
} }
if req.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
}
if req.Composer != "Composer" { if req.Composer != "Composer" {
t.Fatalf("composer = %q", req.Composer) t.Fatalf("composer = %q", req.Composer)
} }
+251 -41
View File
@@ -96,6 +96,15 @@ type ExtDownloadURLResult struct {
SampleRate int `json:"sample_rate,omitempty"` 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 { type ExtDownloadResult struct {
Success bool `json:"success"` Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"` FilePath string `json:"file_path,omitempty"`
@@ -104,16 +113,90 @@ type ExtDownloadResult struct {
ErrorMessage string `json:"error_message,omitempty"` ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"` ErrorType string `json:"error_type,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"` Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"` Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"` AlbumArtist string `json:"album_artist,omitempty"`
TrackNumber int `json:"track_number,omitempty"` TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
ReleaseDate string `json:"release_date,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
DecryptionKey string `json:"decryption_key,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 { type extensionProviderWrapper struct {
@@ -600,6 +683,14 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
ErrorType: "internal_error", ErrorType: "internal_error",
}, nil }, nil
} }
downloadResult.Decryption = normalizeDownloadDecryptionInfo(
downloadResult.Decryption,
downloadResult.DecryptionKey,
)
downloadResult.DecryptionKey = normalizedDownloadDecryptionKey(
downloadResult.Decryption,
downloadResult.DecryptionKey,
)
return &downloadResult, nil return &downloadResult, nil
} }
@@ -676,6 +767,9 @@ func (m *extensionManager) SearchTracksWithExtensions(query string, limit int) (
var providerPriority []string var providerPriority []string
var providerPriorityMu sync.RWMutex var providerPriorityMu sync.RWMutex
var extensionFallbackProviderIDs []string
var extensionFallbackProviderIDsMu sync.RWMutex
var metadataProviderPriority []string var metadataProviderPriority []string
var metadataProviderPriorityMu sync.RWMutex var metadataProviderPriorityMu sync.RWMutex
@@ -684,8 +778,8 @@ var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
func SetProviderPriority(providerIDs []string) { func SetProviderPriority(providerIDs []string) {
providerPriorityMu.Lock() providerPriorityMu.Lock()
defer providerPriorityMu.Unlock() defer providerPriorityMu.Unlock()
providerPriority = providerIDs providerPriority = sanitizeDownloadProviderPriority(providerIDs)
GoLog("[Extension] Download provider priority set: %v\n", providerIDs) GoLog("[Extension] Download provider priority set: %v\n", providerPriority)
} }
func GetProviderPriority() []string { func GetProviderPriority() []string {
@@ -693,7 +787,7 @@ func GetProviderPriority() []string {
defer providerPriorityMu.RUnlock() defer providerPriorityMu.RUnlock()
if len(providerPriority) == 0 { if len(providerPriority) == 0 {
return []string{"tidal", "qobuz", "deezer"} return []string{"tidal", "qobuz"}
} }
result := make([]string, len(providerPriority)) result := make([]string, len(providerPriority))
@@ -701,6 +795,102 @@ func GetProviderPriority() []string {
return result 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()
if providerIDs == nil {
extensionFallbackProviderIDs = nil
GoLog("[Extension] Extension fallback providers reset to default (all enabled download extensions)\n")
return
}
sanitized := make([]string, 0, len(providerIDs))
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
if providerID == "" || isBuiltInDownloadProvider(strings.ToLower(providerID)) {
continue
}
if _, exists := seen[providerID]; exists {
continue
}
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
extensionFallbackProviderIDs = sanitized
GoLog("[Extension] Extension fallback providers set: %v\n", sanitized)
}
func GetExtensionFallbackProviderIDs() []string {
extensionFallbackProviderIDsMu.RLock()
defer extensionFallbackProviderIDsMu.RUnlock()
if extensionFallbackProviderIDs == nil {
return nil
}
result := make([]string, len(extensionFallbackProviderIDs))
copy(result, extensionFallbackProviderIDs)
return result
}
func isExtensionFallbackAllowed(providerID string) bool {
if isBuiltInDownloadProvider(strings.ToLower(providerID)) {
return true
}
allowed := GetExtensionFallbackProviderIDs()
if allowed == nil {
return true
}
for _, allowedProviderID := range allowed {
if allowedProviderID == providerID {
return true
}
}
return false
}
func SetMetadataProviderPriority(providerIDs []string) { func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock() metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock() defer metadataProviderPriorityMu.Unlock()
@@ -752,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 { func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
deezerID := "" deezerID := ""
tidalID := "" tidalID := ""
@@ -930,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) GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
newPriority := []string{req.Service} newPriority := []string{req.Service}
for _, p := range priority { for _, p := range priority {
@@ -940,7 +1139,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
priority = newPriority priority = newPriority
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority) 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 found := false
for _, p := range priority { for _, p := range priority {
if strings.EqualFold(p, req.Service) { if strings.EqualFold(p, req.Service) {
@@ -1198,14 +1397,17 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey, 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 { if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else { } else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) 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 { if ext.Manifest.SkipMetadataEnrichment {
@@ -1303,14 +1505,19 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue continue
} }
if skipBuiltIn && isBuiltInProvider(providerIDNormalized) { if skipBuiltIn && isBuiltInDownloadProvider(providerIDNormalized) {
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID) GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
continue continue
} }
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) GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerIDNormalized) { if isBuiltInDownloadProvider(providerIDNormalized) {
if (req.Genre == "" || req.Label == "" || req.Copyright == "") && if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
req.ISRC != "" { req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC) GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
@@ -1424,14 +1631,17 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey, 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 { if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else { } else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) 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 { if ext.Manifest.SkipMetadataEnrichment {
@@ -1564,24 +1774,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
} }
} }
err = 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,
}
}
err = deezerErr
default: default:
return nil, fmt.Errorf("unknown built-in provider: %s", providerID) return nil, fmt.Errorf("unknown built-in provider: %s", providerID)
} }
@@ -1609,6 +1801,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
Copyright: req.Copyright, Copyright: req.Copyright,
LyricsLRC: result.LyricsLRC, LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey, DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
}, nil }, nil
} }
@@ -1650,19 +1843,24 @@ func buildOutputPath(req DownloadRequest) string {
outputDir := req.OutputDir outputDir := req.OutputDir
if strings.TrimSpace(outputDir) == "" { if strings.TrimSpace(outputDir) == "" {
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads") 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) return filepath.Join(outputDir, filename+ext)
} }
func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string { func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string {
if strings.TrimSpace(req.OutputPath) != "" { 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) return buildOutputPath(req)
} }
@@ -1703,6 +1901,18 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
return filepath.Join(tempDir, filename+outputExt) 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) { func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
if !p.extension.Manifest.HasCustomSearch() { if !p.extension.Manifest.HasCustomSearch() {
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
+182 -1
View File
@@ -1,6 +1,10 @@
package gobackend package gobackend
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) { func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
original := GetMetadataProviderPriority() original := GetMetadataProviderPriority()
@@ -19,6 +23,183 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
} }
} }
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
got := GetExtensionFallbackProviderIDs()
want := []string{"ext-a", "ext-b"}
if len(got) != len(want) {
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
}
}
}
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs(nil)
if !isExtensionFallbackAllowed("custom-ext") {
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
}
if !isExtensionFallbackAllowed("qobuz") {
t.Fatal("expected built-in provider to remain allowed")
}
}
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
if !isExtensionFallbackAllowed("allowed-ext") {
t.Fatal("expected explicitly allowed extension to be permitted")
}
if isExtensionFallbackAllowed("blocked-ext") {
t.Fatal("expected extension outside allowlist to be blocked")
}
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)
}
}
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) { func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
originalPriority := GetMetadataProviderPriority() originalPriority := GetMetadataProviderPriority()
originalSearch := searchBuiltInMetadataTracksFunc originalSearch := searchBuiltInMetadataTracksFunc
+4
View File
@@ -377,7 +377,9 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
fileObj.Set("exists", r.fileExists) fileObj.Set("exists", r.fileExists)
fileObj.Set("delete", r.fileDelete) fileObj.Set("delete", r.fileDelete)
fileObj.Set("read", r.fileRead) fileObj.Set("read", r.fileRead)
fileObj.Set("readBytes", r.fileReadBytes)
fileObj.Set("write", r.fileWrite) fileObj.Set("write", r.fileWrite)
fileObj.Set("writeBytes", r.fileWriteBytes)
fileObj.Set("copy", r.fileCopy) fileObj.Set("copy", r.fileCopy)
fileObj.Set("move", r.fileMove) fileObj.Set("move", r.fileMove)
fileObj.Set("getSize", r.fileGetSize) fileObj.Set("getSize", r.fileGetSize)
@@ -407,6 +409,8 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("stringifyJSON", r.stringifyJSON) utilsObj.Set("stringifyJSON", r.stringifyJSON)
utilsObj.Set("encrypt", r.cryptoEncrypt) utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt) utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
utilsObj.Set("generateKey", r.cryptoGenerateKey) utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent) utilsObj.Set("randomUserAgent", r.randomUserAgent)
vm.Set("utils", utilsObj) vm.Set("utils", utilsObj)
+359
View File
@@ -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)
}
+185
View File
@@ -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())
}
}
+199
View File
@@ -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 { func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ 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 { func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
+9
View File
@@ -55,6 +55,7 @@ const (
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzStoreBaseURL = "https://www.qobuz.com/us-en" qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download" qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
qobuzZarzDownloadAPIURL = "https://api.zarz.moe/dl/qbz"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/" qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
@@ -105,6 +106,10 @@ type QobuzTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} `json:"performer"` } `json:"performer"`
Composer struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"composer"`
} }
type qobuzImageSet struct { type qobuzImageSet struct {
@@ -349,6 +354,7 @@ func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata {
AlbumID: qobuzPrefixedID(track.Album.ID), AlbumID: qobuzPrefixedID(track.Album.ID),
ArtistID: qobuzTrackArtistID(track), ArtistID: qobuzTrackArtistID(track),
AlbumType: qobuzTrackAlbumType(track), AlbumType: qobuzTrackAlbumType(track),
Composer: strings.TrimSpace(track.Composer.Name),
} }
} }
@@ -373,6 +379,7 @@ func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata {
AlbumID: qobuzPrefixedID(track.Album.ID), AlbumID: qobuzPrefixedID(track.Album.ID),
AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)), AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)),
AlbumType: qobuzTrackAlbumType(track), AlbumType: qobuzTrackAlbumType(track),
Composer: strings.TrimSpace(track.Composer.Name),
} }
} }
@@ -1133,6 +1140,7 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
func (q *QobuzDownloader) GetAvailableAPIs() []string { func (q *QobuzDownloader) GetAvailableAPIs() []string {
return []string{ return []string{
qobuzDownloadAPIURL, qobuzDownloadAPIURL,
qobuzZarzDownloadAPIURL,
qobuzDabMusicAPIURL, qobuzDabMusicAPIURL,
qobuzDeebAPIURL, qobuzDeebAPIURL,
qobuzAfkarAPIURL, qobuzAfkarAPIURL,
@@ -1154,6 +1162,7 @@ const (
func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider { func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
return []qobuzAPIProvider{ return []qobuzAPIProvider{
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL}, {Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
{Name: "zarz", URL: qobuzZarzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard}, {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, {Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard}, {Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
+37 -2
View File
@@ -241,12 +241,13 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
func TestQobuzAvailableProviders(t *testing.T) { func TestQobuzAvailableProviders(t *testing.T) {
providers := NewQobuzDownloader().GetAvailableProviders() providers := NewQobuzDownloader().GetAvailableProviders()
if len(providers) != 5 { if len(providers) != 6 {
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers)) t.Fatalf("expected 6 Qobuz providers, got %d", len(providers))
} }
want := map[string]string{ want := map[string]string{
"musicdl": qobuzAPIKindMusicDL, "musicdl": qobuzAPIKindMusicDL,
"zarz": qobuzAPIKindMusicDL,
"dabmusic": qobuzAPIKindStandard, "dabmusic": qobuzAPIKindStandard,
"deeb": qobuzAPIKindStandard, "deeb": qobuzAPIKindStandard,
"qbz": qobuzAPIKindStandard, "qbz": qobuzAPIKindStandard,
@@ -518,3 +519,37 @@ func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification") t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
} }
} }
func TestQobuzTrackMetadataIncludesComposer(t *testing.T) {
track := &QobuzTrack{
ID: 40681594,
Title: "Sign of the Times",
ISRC: "USSM11703595",
Duration: 340,
TrackNumber: 1,
MediaNumber: 1,
}
track.Performer.ID = 729886
track.Performer.Name = "Harry Styles"
track.Composer.ID = 729886
track.Composer.Name = "Harry Styles"
track.Album.ID = "0886446451985"
track.Album.Title = "Harry Styles"
track.Album.ReleaseDate = "2017-05-12"
track.Album.TracksCount = 10
track.Album.ReleaseType = "album"
track.Album.ProductType = "album"
track.Album.Artist.ID = 729886
track.Album.Artist.Name = "Harry Styles"
track.Album.Artists = []qobuzArtistRef{{ID: 729886, Name: "Harry Styles"}}
trackMeta := qobuzTrackToTrackMetadata(track)
if trackMeta.Composer != "Harry Styles" {
t.Fatalf("track composer = %q", trackMeta.Composer)
}
albumTrackMeta := qobuzTrackToAlbumTrackMetadata(track)
if albumTrackMeta.Composer != "Harry Styles" {
t.Fatalf("album track composer = %q", albumTrackMeta.Composer)
}
}
+7
View File
@@ -607,6 +607,13 @@ import Gobackend // Import Go framework
let response = GobackendGetProviderPriorityJSON(&error) let response = GobackendGetProviderPriorityJSON(&error)
if let error = error { throw error } if let error = error { throw error }
return response return response
case "setDownloadFallbackExtensionIds":
let args = call.arguments as! [String: Any]
let extensionIdsJson = args["extension_ids"] as? String ?? ""
GobackendSetExtensionFallbackProviderIDsJSON(extensionIdsJson, &error)
if let error = error { throw error }
return nil
case "setMetadataProviderPriority": case "setMetadataProviderPriority":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '4.2.1'; static const String version = '4.2.2';
static const String buildNumber = '122'; static const String buildNumber = '123';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release. /// Shows "Internal" in debug builds, actual version in release.
+30
View File
@@ -1738,6 +1738,24 @@ abstract class AppLocalizations {
/// **'If a track is not available on the first provider, the app will automatically try the next one.'** /// **'If a track is not available on the first provider, the app will automatically try the next one.'**
String get providerPriorityInfo; String get providerPriorityInfo;
/// Section title for choosing which download extensions can be used as fallback providers
///
/// In en, this message translates to:
/// **'Extension Fallback'**
String get providerPriorityFallbackExtensionsTitle;
/// Section description for extension fallback selection
///
/// In en, this message translates to:
/// **'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.'**
String get providerPriorityFallbackExtensionsDescription;
/// Hint below the extension fallback selection list
///
/// In en, this message translates to:
/// **'Only enabled extensions with download-provider capability are listed here.'**
String get providerPriorityFallbackExtensionsHint;
/// Label for built-in providers (Tidal/Qobuz) /// Label for built-in providers (Tidal/Qobuz)
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2644,6 +2662,18 @@ abstract class AppLocalizations {
/// **'Set download service order'** /// **'Set download service order'**
String get extensionsDownloadPrioritySubtitle; String get extensionsDownloadPrioritySubtitle;
/// Setting and page title for choosing which download extensions can be used during fallback
///
/// In en, this message translates to:
/// **'Fallback Extensions'**
String get extensionsFallbackTitle;
/// Subtitle for download fallback extensions menu
///
/// In en, this message translates to:
/// **'Choose which installed download extensions can be used as fallback'**
String get extensionsFallbackSubtitle;
/// Empty state - no download providers /// Empty state - no download providers
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+18
View File
@@ -940,6 +940,17 @@ class AppLocalizationsDe extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'Wenn kein Titel bei dem ersten Anbieter nicht verfügbar ist, wird die App automatisch den nächsten versuchen.'; 'Wenn kein Titel bei dem ersten Anbieter nicht verfügbar ist, wird die App automatisch den nächsten versuchen.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override @override
String get providerBuiltIn => 'Integriert'; String get providerBuiltIn => 'Integriert';
@@ -1438,6 +1449,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle => String get extensionsDownloadPrioritySubtitle =>
'Download-Service-Reihenfolge festlegen'; 'Download-Service-Reihenfolge festlegen';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'Keine Erweiterungen mit Download-Provider'; 'Keine Erweiterungen mit Download-Provider';
+18
View File
@@ -926,6 +926,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; 'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1426,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+18
View File
@@ -926,6 +926,17 @@ class AppLocalizationsEs extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; 'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1426,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+18
View File
@@ -928,6 +928,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; 'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1417,6 +1428,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+18
View File
@@ -926,6 +926,17 @@ class AppLocalizationsHi extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; 'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1426,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+18
View File
@@ -930,6 +930,17 @@ class AppLocalizationsId extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.'; 'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Fallback Ekstensi';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.';
@override @override
String get providerBuiltIn => 'Bawaan'; String get providerBuiltIn => 'Bawaan';
@@ -1423,6 +1434,13 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle => String get extensionsDownloadPrioritySubtitle =>
'Atur urutan layanan unduhan'; 'Atur urutan layanan unduhan';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'Tidak ada ekstensi dengan provider unduhan'; 'Tidak ada ekstensi dengan provider unduhan';
+18
View File
@@ -920,6 +920,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; 'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override @override
String get providerBuiltIn => '内蔵'; String get providerBuiltIn => '内蔵';
@@ -1409,6 +1420,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定'; String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません'; String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません';
+18
View File
@@ -908,6 +908,17 @@ class AppLocalizationsKo extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; 'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1395,6 +1406,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+18
View File
@@ -926,6 +926,17 @@ class AppLocalizationsNl extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; 'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1426,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+18
View File
@@ -926,6 +926,17 @@ class AppLocalizationsPt extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; 'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1426,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+18
View File
@@ -940,6 +940,17 @@ class AppLocalizationsRu extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.'; 'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override @override
String get providerBuiltIn => 'Встроенные'; String get providerBuiltIn => 'Встроенные';
@@ -1439,6 +1450,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle => String get extensionsDownloadPrioritySubtitle =>
'Установка порядок сервисов скачивания'; 'Установка порядок сервисов скачивания';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'Нет расширений с провайдером загрузки'; 'Нет расширений с провайдером загрузки';
+18
View File
@@ -931,6 +931,17 @@ class AppLocalizationsTr extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'Eğer bir şarkı ilk hizmette mevcut değilse uygulama otomatik olarak bir sonrakini deneyecektir.'; 'Eğer bir şarkı ilk hizmette mevcut değilse uygulama otomatik olarak bir sonrakini deneyecektir.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override @override
String get providerBuiltIn => 'Dahili'; String get providerBuiltIn => 'Dahili';
@@ -1421,6 +1432,13 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+18
View File
@@ -926,6 +926,17 @@ class AppLocalizationsZh extends AppLocalizations {
String get providerPriorityInfo => String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.'; 'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override @override
String get providerBuiltIn => 'Built-in'; String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1426,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get extensionsDownloadPrioritySubtitle => 'Set download service order'; String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'No extensions with download provider'; 'No extensions with download provider';
+20
View File
@@ -1203,6 +1203,18 @@
"@providerPriorityInfo": { "@providerPriorityInfo": {
"description": "Info tip about fallback behavior" "description": "Info tip about fallback behavior"
}, },
"providerPriorityFallbackExtensionsTitle": "Extension Fallback",
"@providerPriorityFallbackExtensionsTitle": {
"description": "Section title for choosing which download extensions can be used as fallback providers"
},
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
"@providerPriorityFallbackExtensionsDescription": {
"description": "Section description for extension fallback selection"
},
"providerPriorityFallbackExtensionsHint": "Only enabled extensions with download-provider capability are listed here.",
"@providerPriorityFallbackExtensionsHint": {
"description": "Hint below the extension fallback selection list"
},
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)" "description": "Label for built-in providers (Tidal/Qobuz)"
@@ -1857,6 +1869,14 @@
"@extensionsDownloadPrioritySubtitle": { "@extensionsDownloadPrioritySubtitle": {
"description": "Subtitle for download priority" "description": "Subtitle for download priority"
}, },
"extensionsFallbackTitle": "Fallback Extensions",
"@extensionsFallbackTitle": {
"description": "Setting and page title for choosing which download extensions can be used during fallback"
},
"extensionsFallbackSubtitle": "Choose which installed download extensions can be used as fallback",
"@extensionsFallbackSubtitle": {
"description": "Subtitle for download fallback extensions menu"
},
"extensionsNoDownloadProvider": "No extensions with download provider", "extensionsNoDownloadProvider": "No extensions with download provider",
"@extensionsNoDownloadProvider": { "@extensionsNoDownloadProvider": {
"description": "Empty state - no download providers" "description": "Empty state - no download providers"
+20
View File
@@ -1119,6 +1119,18 @@
"@providerPriorityInfo": { "@providerPriorityInfo": {
"description": "Info tip about fallback behavior" "description": "Info tip about fallback behavior"
}, },
"providerPriorityFallbackExtensionsTitle": "Fallback Ekstensi",
"@providerPriorityFallbackExtensionsTitle": {
"description": "Section title for choosing which download extensions can be used as fallback providers"
},
"providerPriorityFallbackExtensionsDescription": "Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.",
"@providerPriorityFallbackExtensionsDescription": {
"description": "Section description for extension fallback selection"
},
"providerPriorityFallbackExtensionsHint": "Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.",
"@providerPriorityFallbackExtensionsHint": {
"description": "Hint below the extension fallback selection list"
},
"providerBuiltIn": "Bawaan", "providerBuiltIn": "Bawaan",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)" "description": "Label for built-in providers (Tidal/Qobuz)"
@@ -1713,6 +1725,14 @@
"@extensionsDownloadPrioritySubtitle": { "@extensionsDownloadPrioritySubtitle": {
"description": "Subtitle for download priority" "description": "Subtitle for download priority"
}, },
"extensionsFallbackTitle": "Fallback Extensions",
"@extensionsFallbackTitle": {
"description": "Setting and page title for choosing which download extensions can be used during fallback"
},
"extensionsFallbackSubtitle": "Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback",
"@extensionsFallbackSubtitle": {
"description": "Subtitle for download fallback extensions menu"
},
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan", "extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
"@extensionsNoDownloadProvider": { "@extensionsNoDownloadProvider": {
"description": "Empty state - no download providers" "description": "Empty state - no download providers"
+7
View File
@@ -33,6 +33,7 @@ class AppSettings {
final bool askQualityBeforeDownload; final bool askQualityBeforeDownload;
final bool enableLogging; final bool enableLogging;
final bool useExtensionProviders; final bool useExtensionProviders;
final List<String>? downloadFallbackExtensionIds;
final String? searchProvider; final String? searchProvider;
final String? homeFeedProvider; final String? homeFeedProvider;
final bool separateSingles; final bool separateSingles;
@@ -108,6 +109,7 @@ class AppSettings {
this.askQualityBeforeDownload = true, this.askQualityBeforeDownload = true,
this.enableLogging = false, this.enableLogging = false,
this.useExtensionProviders = true, this.useExtensionProviders = true,
this.downloadFallbackExtensionIds,
this.searchProvider, this.searchProvider,
this.homeFeedProvider, this.homeFeedProvider,
this.separateSingles = false, this.separateSingles = false,
@@ -170,6 +172,8 @@ class AppSettings {
bool? askQualityBeforeDownload, bool? askQualityBeforeDownload,
bool? enableLogging, bool? enableLogging,
bool? useExtensionProviders, bool? useExtensionProviders,
List<String>? downloadFallbackExtensionIds,
bool clearDownloadFallbackExtensionIds = false,
String? searchProvider, String? searchProvider,
bool clearSearchProvider = false, bool clearSearchProvider = false,
String? homeFeedProvider, String? homeFeedProvider,
@@ -232,6 +236,9 @@ class AppSettings {
enableLogging: enableLogging ?? this.enableLogging, enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders: useExtensionProviders:
useExtensionProviders ?? this.useExtensionProviders, useExtensionProviders ?? this.useExtensionProviders,
downloadFallbackExtensionIds: clearDownloadFallbackExtensionIds
? null
: (downloadFallbackExtensionIds ?? this.downloadFallbackExtensionIds),
searchProvider: clearSearchProvider searchProvider: clearSearchProvider
? null ? null
: (searchProvider ?? this.searchProvider), : (searchProvider ?? this.searchProvider),
+5
View File
@@ -35,6 +35,10 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
enableLogging: json['enableLogging'] as bool? ?? false, enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
downloadFallbackExtensionIds:
(json['downloadFallbackExtensionIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
searchProvider: json['searchProvider'] as String?, searchProvider: json['searchProvider'] as String?,
homeFeedProvider: json['homeFeedProvider'] as String?, homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false, separateSingles: json['separateSingles'] as bool? ?? false,
@@ -105,6 +109,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'askQualityBeforeDownload': instance.askQualityBeforeDownload, 'askQualityBeforeDownload': instance.askQualityBeforeDownload,
'enableLogging': instance.enableLogging, 'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders, 'useExtensionProviders': instance.useExtensionProviders,
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
'searchProvider': instance.searchProvider, 'searchProvider': instance.searchProvider,
'homeFeedProvider': instance.homeFeedProvider, 'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles, 'separateSingles': instance.separateSingles,
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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)) { if (!result.contains(provider)) {
result.add(provider); result.add(provider);
} }
@@ -896,7 +896,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
List<String> getAllDownloadProviders() { List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz', 'deezer']; final providers = ['tidal', 'qobuz'];
for (final ext in state.extensions) { for (final ext in state.extensions) {
if (ext.enabled && ext.hasDownloadProvider) { if (ext.enabled && ext.hasDownloadProvider) {
providers.add(ext.id); providers.add(ext.id);
+53 -4
View File
@@ -12,7 +12,7 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings'; const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version'; const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 9; const _currentMigrationVersion = 10;
const _spotifyClientSecretKey = 'spotify_client_secret'; const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider'); final _log = AppLogger('SettingsProvider');
@@ -35,9 +35,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
final prefs = await _prefs; final prefs = await _prefs;
final json = prefs.getString(_settingsKey); final json = prefs.getString(_settingsKey);
if (json != null) { if (json != null) {
state = AppSettings.fromJson( final loaded = AppSettings.fromJson(
Map<String, dynamic>.from(jsonDecode(json) as Map), Map<String, dynamic>.from(jsonDecode(json) as Map),
); );
final sanitizedDownloadFallbackExtensionIds =
_sanitizeDownloadFallbackExtensionIds(
loaded.downloadFallbackExtensionIds,
);
state = loaded.copyWith(
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
clearDownloadFallbackExtensionIds:
loaded.downloadFallbackExtensionIds != null &&
sanitizedDownloadFallbackExtensionIds == null,
);
await _runMigrations(prefs); await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded(); await _normalizeIosDownloadDirectoryIfNeeded();
@@ -50,6 +60,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
_syncLyricsSettingsToBackend(); _syncLyricsSettingsToBackend();
_syncNetworkCompatibilitySettingsToBackend(); _syncNetworkCompatibilitySettingsToBackend();
_syncExtensionFallbackSettingsToBackend();
} }
void _syncLyricsSettingsToBackend() { void _syncLyricsSettingsToBackend() {
@@ -83,6 +94,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
}); });
} }
void _syncExtensionFallbackSettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
PlatformBridge.setDownloadFallbackExtensionIds(
state.downloadFallbackExtensionIds,
).catchError((Object e) {
_log.w('Failed to sync extension fallback settings to backend: $e');
});
}
Future<void> _runMigrations(SharedPreferences prefs) async { Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
@@ -111,8 +132,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
); );
} }
state = state.copyWith(lastSeenVersion: AppInfo.version); state = state.copyWith(lastSeenVersion: AppInfo.version);
// Migration 7: YouTube is no longer a built-in service reset to Tidal // Migration 7/10: retired built-in services reset back to Tidal
if (state.defaultService == 'youtube') { if (state.defaultService == 'youtube' ||
state.defaultService == 'deezer') {
state = state.copyWith(defaultService: 'tidal'); state = state.copyWith(defaultService: 'tidal');
} }
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
@@ -172,6 +194,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings(); await _saveSettings();
} }
List<String>? _sanitizeDownloadFallbackExtensionIds(List<String>? ids) {
if (ids == null) {
return null;
}
final result = <String>[];
for (final id in ids) {
final normalized = id.trim();
if (normalized.isEmpty || result.contains(normalized)) {
continue;
}
result.add(normalized);
}
return result;
}
Future<void> _cleanupRetiredSpotifySettings() async { Future<void> _cleanupRetiredSpotifySettings() async {
final storedSecret = await _secureStorage.read( final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey, key: _spotifyClientSecretKey,
@@ -390,6 +428,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setDownloadFallbackExtensionIds(List<String>? extensionIds) {
final sanitized = _sanitizeDownloadFallbackExtensionIds(extensionIds);
state = state.copyWith(
downloadFallbackExtensionIds: sanitized,
clearDownloadFallbackExtensionIds:
extensionIds == null && state.downloadFallbackExtensionIds != null,
);
_saveSettings();
_syncExtensionFallbackSettingsToBackend();
}
void setSeparateSingles(bool enabled) { void setSeparateSingles(bool enabled) {
state = state.copyWith(separateSingles: enabled); state = state.copyWith(separateSingles: enabled);
_saveSettings(); _saveSettings();
+2 -2
View File
@@ -908,7 +908,7 @@ class TrackNotifier extends Notifier<TrackState> {
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?, totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?, albumType: normalizeOptionalString(data['album_type']?.toString()),
totalTracks: data['total_tracks'] as int?, totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(), composer: data['composer']?.toString(),
); );
@@ -945,7 +945,7 @@ class TrackNotifier extends Notifier<TrackState> {
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?, totalTracks: data['total_tracks'] as int?,
source: effectiveSource, source: effectiveSource,
albumType: data['album_type']?.toString(), albumType: normalizeOptionalString(data['album_type']?.toString()),
composer: data['composer']?.toString(), composer: data['composer']?.toString(),
itemType: itemType, itemType: itemType,
); );
+77 -20
View File
@@ -75,6 +75,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
String? _error; String? _error;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
String? _artistId; String? _artistId;
String? _albumType;
int? _albumTotalTracks;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@override @override
@@ -112,6 +114,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_tracks = _AlbumCache.get(widget.albumId); _tracks = _AlbumCache.get(widget.albumId);
} }
_artistId = widget.artistId; _artistId = widget.artistId;
_albumType = _tracks?.firstOrNull?.albumType;
_albumTotalTracks = _tracks?.firstOrNull?.totalTracks;
if (_tracks == null || _tracks!.isEmpty) { if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks(); _fetchTracks();
@@ -179,13 +183,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
deezerAlbumId, deezerAlbumId,
); );
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?; final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
@@ -193,6 +206,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -204,13 +219,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
qobuzAlbumId, qobuzAlbumId,
); );
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?; final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
@@ -218,6 +242,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -229,13 +255,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
tidalAlbumId, tidalAlbumId,
); );
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?; final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
@@ -243,6 +278,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -255,13 +292,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
final trackList = result['tracks'] as List<dynamic>; final trackList = result['tracks'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = result['album'] as Map<String, dynamic>?; final albumInfo = result['album'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
@@ -269,6 +315,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -284,7 +332,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
} }
Track _parseTrack(Map<String, dynamic> data) { Track _parseTrack(
Map<String, dynamic> data, {
String? albumTypeFallback,
int? totalTracksFallback,
}) {
return Track( return Track(
id: data['spotify_id'] as String? ?? '', id: data['spotify_id'] as String? ?? '',
name: data['name'] as String? ?? '', name: data['name'] as String? ?? '',
@@ -301,8 +353,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?, totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?, albumType:
totalTracks: data['total_tracks'] as int?, normalizeOptionalString(data['album_type']?.toString()) ??
albumTypeFallback ??
_albumType,
totalTracks:
data['total_tracks'] as int? ??
totalTracksFallback ??
_albumTotalTracks,
composer: data['composer']?.toString(), composer: data['composer']?.toString(),
); );
} }
@@ -313,7 +371,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
if (widget.albumId.startsWith('tidal:')) return 'tidal'; if (widget.albumId.startsWith('tidal:')) return 'tidal';
if (widget.albumId.startsWith('qobuz:')) return 'qobuz'; if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
if (widget.albumId.startsWith('deezer:')) return 'deezer';
return null; return null;
} }
+7 -3
View File
@@ -159,7 +159,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
} }
if (widget.artistId.startsWith('tidal:')) return 'tidal'; if (widget.artistId.startsWith('tidal:')) return 'tidal';
if (widget.artistId.startsWith('qobuz:')) return 'qobuz'; if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
if (widget.artistId.startsWith('deezer:')) return 'deezer';
return null; return null;
} }
@@ -412,7 +411,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?, totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
albumType: data['album_type']?.toString() ?? album?.albumType, albumType:
normalizeOptionalString(data['album_type']?.toString()) ??
album?.albumType,
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks, totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
composer: data['composer']?.toString(), composer: data['composer']?.toString(),
source: data['provider_id']?.toString() ?? widget.extensionId, source: data['provider_id']?.toString() ?? widget.extensionId,
@@ -1057,9 +1058,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
); );
if (result != null && result['tracks'] != null) { if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>; final tracksList = result['tracks'] as List<dynamic>;
return tracksList final parsedTracks = tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album)) .map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList(); .toList();
return parsedTracks;
} }
} else if (album.id.startsWith('deezer:')) { } else if (album.id.startsWith('deezer:')) {
final deezerId = album.id.replaceFirst('deezer:', ''); final deezerId = album.id.replaceFirst('deezer:', '');
@@ -1934,6 +1936,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
albumId: album.id, albumId: album.id,
albumName: album.name, albumName: album.name,
coverUrl: album.coverUrl, coverUrl: album.coverUrl,
initialAlbumType: album.albumType,
initialTotalTracks: album.totalTracks,
), ),
), ),
); );
+34 -5
View File
@@ -299,7 +299,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}); });
} }
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async { Future<void> _navigateToMetadataScreen(
DownloadHistoryItem item, {
required List<DownloadHistoryItem> navigationItems,
required int navigationIndex,
}) async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
_precacheCover(item.coverUrl); _precacheCover(item.coverUrl);
final beforeModTime = final beforeModTime =
@@ -309,7 +313,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)), slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
); );
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath, item.filePath,
@@ -691,7 +701,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
key: ValueKey(track.id), key: ValueKey(track.id),
child: StaggeredListItem( child: StaggeredListItem(
index: index, index: index,
child: _buildTrackItem(context, colorScheme, track), child: _buildTrackItem(
context,
colorScheme,
track,
tracks,
index,
),
), ),
); );
}, childCount: tracks.length), }, childCount: tracks.length),
@@ -709,12 +725,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children.add(_buildDiscSeparator(context, colorScheme, discNumber)); children.add(_buildDiscSeparator(context, colorScheme, discNumber));
for (final track in discTracks) { for (final track in discTracks) {
final navigationIndex = tracks.indexOf(track);
children.add( children.add(
KeyedSubtree( KeyedSubtree(
key: ValueKey(track.id), key: ValueKey(track.id),
child: StaggeredListItem( child: StaggeredListItem(
index: revealIndex++, index: revealIndex++,
child: _buildTrackItem(context, colorScheme, track), child: _buildTrackItem(
context,
colorScheme,
track,
tracks,
navigationIndex,
),
), ),
), ),
); );
@@ -774,6 +797,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
BuildContext context, BuildContext context,
ColorScheme colorScheme, ColorScheme colorScheme,
DownloadHistoryItem track, DownloadHistoryItem track,
List<DownloadHistoryItem> navigationItems,
int navigationIndex,
) { ) {
final isSelected = _selectedIds.contains(track.id); final isSelected = _selectedIds.contains(track.id);
@@ -791,7 +816,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
), ),
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(track.id) ? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(track), : () => _navigateToMetadataScreen(
track,
navigationItems: navigationItems,
navigationIndex: navigationIndex,
),
onLongPress: _isSelectionMode onLongPress: _isSelectionMode
? null ? null
: () => _enterSelectionMode(track.id), : () => _enterSelectionMode(track.id),
+57 -9
View File
@@ -1443,7 +1443,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
button: true, button: true,
label: 'Open track ${item.trackName} by ${item.artistName}', label: 'Open track ${item.trackName} by ${item.artistName}',
child: GestureDetector( child: GestureDetector(
onTap: () => _navigateToMetadataScreen(item), onTap: () => _navigateToMetadataScreen(
item,
navigationItems: items
.take(itemCount)
.toList(growable: false),
navigationIndex: index,
),
child: Container( child: Container(
width: coverSize, width: coverSize,
margin: const EdgeInsets.only(right: 12), margin: const EdgeInsets.only(right: 12),
@@ -2217,7 +2223,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
} }
} }
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async { Future<void> _navigateToMetadataScreen(
DownloadHistoryItem item, {
List<DownloadHistoryItem>? navigationItems,
int? navigationIndex,
}) async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
_precacheCover(item.coverUrl); _precacheCover(item.coverUrl);
final beforeModTime = final beforeModTime =
@@ -2226,7 +2236,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
); );
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)), slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
); );
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath, item.filePath,
@@ -3015,6 +3031,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
albumId: albumItem.id, albumId: albumItem.id,
albumName: albumItem.name, albumName: albumItem.name,
coverUrl: albumItem.coverUrl, coverUrl: albumItem.coverUrl,
initialAlbumType: albumItem.albumType,
initialTotalTracks: albumItem.totalTracks,
), ),
), ),
); );
@@ -4299,6 +4317,8 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
final String albumId; final String albumId;
final String albumName; final String albumName;
final String? coverUrl; final String? coverUrl;
final String? initialAlbumType;
final int? initialTotalTracks;
const ExtensionAlbumScreen({ const ExtensionAlbumScreen({
super.key, super.key,
@@ -4306,6 +4326,8 @@ class ExtensionAlbumScreen extends ConsumerStatefulWidget {
required this.albumId, required this.albumId,
required this.albumName, required this.albumName,
this.coverUrl, this.coverUrl,
this.initialAlbumType,
this.initialTotalTracks,
}); });
@override @override
@@ -4319,10 +4341,14 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
String? _error; String? _error;
String? _artistId; String? _artistId;
String? _artistName; String? _artistName;
String? _albumType;
int? _albumTotalTracks;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_albumType = normalizeOptionalString(widget.initialAlbumType);
_albumTotalTracks = widget.initialTotalTracks;
_fetchTracks(); _fetchTracks();
} }
@@ -4356,17 +4382,28 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
return; return;
} }
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final artistId = (result['artist_id'] ?? result['artistId'])?.toString(); final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
final artistName = result['artists'] as String?; final artistName = result['artists'] as String?;
final albumType =
normalizeOptionalString(result['album_type']?.toString()) ??
_albumType;
final totalTracks = result['total_tracks'] as int? ?? _albumTotalTracks;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_artistName = artistName; _artistName = artistName;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } catch (e) {
@@ -4378,7 +4415,11 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
} }
} }
Track _parseTrack(Map<String, dynamic> data) { Track _parseTrack(
Map<String, dynamic> data, {
String? albumTypeFallback,
int? totalTracksFallback,
}) {
int durationMs = 0; int durationMs = 0;
final durationValue = data['duration_ms']; final durationValue = data['duration_ms'];
if (durationValue is int) { if (durationValue is int) {
@@ -4406,7 +4447,14 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?, totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?, albumType:
normalizeOptionalString(data['album_type']?.toString()) ??
albumTypeFallback ??
_albumType,
totalTracks:
data['total_tracks'] as int? ??
totalTracksFallback ??
_albumTotalTracks,
composer: data['composer']?.toString(), composer: data['composer']?.toString(),
source: widget.extensionId, source: widget.extensionId,
); );
-2
View File
@@ -61,7 +61,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
if (playlistId != null) { if (playlistId != null) {
if (playlistId.startsWith('tidal:')) return 'tidal'; if (playlistId.startsWith('tidal:')) return 'tidal';
if (playlistId.startsWith('qobuz:')) return 'qobuz'; if (playlistId.startsWith('qobuz:')) return 'qobuz';
if (playlistId.startsWith('deezer:')) return 'deezer';
} }
final source = _tracks.firstOrNull?.source; final source = _tracks.firstOrNull?.source;
@@ -72,7 +71,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final trackId = _tracks.firstOrNull?.id ?? ''; final trackId = _tracks.firstOrNull?.id ?? '';
if (trackId.startsWith('tidal:')) return 'tidal'; if (trackId.startsWith('tidal:')) return 'tidal';
if (trackId.startsWith('qobuz:')) return 'qobuz'; if (trackId.startsWith('qobuz:')) return 'qobuz';
if (trackId.startsWith('deezer:')) return 'deezer';
return null; return null;
} }
+113 -13
View File
@@ -2963,15 +2963,23 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
Future<void> _navigateToHistoryMetadataScreen( Future<void> _navigateToHistoryMetadataScreen(
DownloadHistoryItem item, DownloadHistoryItem item, {
) async { List<DownloadHistoryItem>? navigationItems,
int? navigationIndex,
}) async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
_precacheCover(item.coverUrl); _precacheCover(item.coverUrl);
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
final beforeModTime = await _readFileModTimeMillis(item.filePath); final beforeModTime = await _readFileModTimeMillis(item.filePath);
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)), slidePageRoute<bool>(
page: TrackMetadataScreen(
item: item,
historyNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
); );
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
if (result == true) { if (result == true) {
@@ -2988,11 +2996,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
); );
} }
void _navigateToLocalMetadataScreen(LocalLibraryItem item) { void _navigateToLocalMetadataScreen(
LocalLibraryItem item, {
List<LocalLibraryItem>? navigationItems,
int? navigationIndex,
}) {
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
Navigator.push( Navigator.push(
context, context,
slidePageRoute<void>(page: TrackMetadataScreen(localItem: item)), slidePageRoute<void>(
page: TrackMetadataScreen(
localItem: item,
localNavigationItems: navigationItems,
navigationIndex: navigationIndex,
),
),
).then((_) => _searchFocusNode.unfocus()); ).then((_) => _searchFocusNode.unfocus());
} }
@@ -4227,6 +4245,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final filteredUnifiedItems = filterData.filteredUnifiedItems; final filteredUnifiedItems = filterData.filteredUnifiedItems;
final totalTrackCount = filterData.totalTrackCount; final totalTrackCount = filterData.totalTrackCount;
final totalAlbumCount = filterData.totalAlbumCount; final totalAlbumCount = filterData.totalAlbumCount;
final downloadedNavigationItems = <DownloadHistoryItem>[];
final downloadedNavigationIndexByUnifiedId = <String, int>{};
final localNavigationItems = <LocalLibraryItem>[];
final localNavigationIndexByUnifiedId = <String, int>{};
for (final item in filteredUnifiedItems) {
final historyItem = item.historyItem;
if (historyItem != null) {
downloadedNavigationIndexByUnifiedId[item.id] =
downloadedNavigationItems.length;
downloadedNavigationItems.add(historyItem);
}
final localItem = item.localItem;
if (localItem != null) {
localNavigationIndexByUnifiedId[item.id] = localNavigationItems.length;
localNavigationItems.add(localItem);
}
}
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
@@ -4419,12 +4456,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems:
downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
), ),
child: _buildUnifiedGridItem( child: _buildUnifiedGridItem(
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems:
downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
), ),
); );
@@ -4472,12 +4523,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems:
downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
), ),
child: _buildUnifiedLibraryItem( child: _buildUnifiedLibraryItem(
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems: downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
), ),
); );
@@ -4540,6 +4604,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems: downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
); );
}, childCount: filteredUnifiedItems.length), }, childCount: filteredUnifiedItems.length),
@@ -4554,6 +4624,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context, context,
item, item,
colorScheme, colorScheme,
downloadedNavigationItems: downloadedNavigationItems,
downloadedNavigationIndex:
downloadedNavigationIndexByUnifiedId[item.id],
localNavigationItems: localNavigationItems,
localNavigationIndex:
localNavigationIndexByUnifiedId[item.id],
), ),
); );
}, childCount: filteredUnifiedItems.length), }, childCount: filteredUnifiedItems.length),
@@ -6609,8 +6685,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Widget _buildUnifiedLibraryItem( Widget _buildUnifiedLibraryItem(
BuildContext context, BuildContext context,
UnifiedLibraryItem item, UnifiedLibraryItem item,
ColorScheme colorScheme, ColorScheme colorScheme, {
) { required List<DownloadHistoryItem> downloadedNavigationItems,
required int? downloadedNavigationIndex,
required List<LocalLibraryItem> localNavigationItems,
required int? localNavigationIndex,
}) {
final fileExistsListenable = _fileExistsListenable(item.filePath); final fileExistsListenable = _fileExistsListenable(item.filePath);
final isSelected = _selectedIds.contains(item.id); final isSelected = _selectedIds.contains(item.id);
final date = item.addedAt; final date = item.addedAt;
@@ -6640,9 +6720,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(item.id) ? () => _toggleSelection(item.id)
: isDownloaded : isDownloaded
? () => _navigateToHistoryMetadataScreen(item.historyItem!) ? () => _navigateToHistoryMetadataScreen(
item.historyItem!,
navigationItems: downloadedNavigationItems,
navigationIndex: downloadedNavigationIndex,
)
: item.localItem != null : item.localItem != null
? () => _navigateToLocalMetadataScreen(item.localItem!) ? () => _navigateToLocalMetadataScreen(
item.localItem!,
navigationItems: localNavigationItems,
navigationIndex: localNavigationIndex,
)
: () => _openFile( : () => _openFile(
item.filePath, item.filePath,
title: item.trackName, title: item.trackName,
@@ -6816,8 +6904,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Widget _buildUnifiedGridItem( Widget _buildUnifiedGridItem(
BuildContext context, BuildContext context,
UnifiedLibraryItem item, UnifiedLibraryItem item,
ColorScheme colorScheme, ColorScheme colorScheme, {
) { required List<DownloadHistoryItem> downloadedNavigationItems,
required int? downloadedNavigationIndex,
required List<LocalLibraryItem> localNavigationItems,
required int? localNavigationIndex,
}) {
final fileExistsListenable = _fileExistsListenable(item.filePath); final fileExistsListenable = _fileExistsListenable(item.filePath);
final isSelected = _selectedIds.contains(item.id); final isSelected = _selectedIds.contains(item.id);
final isDownloaded = item.source == LibraryItemSource.downloaded; final isDownloaded = item.source == LibraryItemSource.downloaded;
@@ -6826,9 +6918,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(item.id) ? () => _toggleSelection(item.id)
: isDownloaded : isDownloaded
? () => _navigateToHistoryMetadataScreen(item.historyItem!) ? () => _navigateToHistoryMetadataScreen(
item.historyItem!,
navigationItems: downloadedNavigationItems,
navigationIndex: downloadedNavigationIndex,
)
: item.localItem != null : item.localItem != null
? () => _navigateToLocalMetadataScreen(item.localItem!) ? () => _navigateToLocalMetadataScreen(
item.localItem!,
navigationItems: localNavigationItems,
navigationIndex: localNavigationIndex,
)
: () => _openFile( : () => _openFile(
item.filePath, item.filePath,
title: item.trackName, title: item.trackName,
@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadFallbackExtensionsPage extends ConsumerStatefulWidget {
const DownloadFallbackExtensionsPage({super.key});
@override
ConsumerState<DownloadFallbackExtensionsPage> createState() =>
_DownloadFallbackExtensionsPageState();
}
class _DownloadFallbackExtensionsPageState
extends ConsumerState<DownloadFallbackExtensionsPage> {
late List<Extension> _extensions;
late Set<String> _selectedExtensionIds;
bool _hasChanges = false;
@override
void initState() {
super.initState();
_loadExtensions();
}
void _loadExtensions() {
final extState = ref.read(extensionProvider);
final settings = ref.read(settingsProvider);
_extensions = extState.extensions
.where(
(extension) => extension.enabled && extension.hasDownloadProvider,
)
.toList();
final savedIds = settings.downloadFallbackExtensionIds;
if (savedIds == null) {
_selectedExtensionIds = _extensions
.map((extension) => extension.id)
.toSet();
} else {
final allowedIds = _extensions.map((extension) => extension.id).toSet();
_selectedExtensionIds = savedIds
.where((extensionId) => allowedIds.contains(extensionId))
.toSet();
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: !_hasChanges,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _confirmDiscard(context);
if (shouldPop && context.mounted) {
Navigator.pop(context);
}
},
child: Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () async {
if (_hasChanges) {
final shouldPop = await _confirmDiscard(context);
if (shouldPop && context.mounted) {
Navigator.pop(context);
}
} else {
Navigator.pop(context);
}
},
),
actions: [
if (_hasChanges)
TextButton(
onPressed: _saveChanges,
child: Text(context.l10n.dialogSave),
),
],
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
context.l10n.extensionsFallbackTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
context.l10n.providerPriorityFallbackExtensionsDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
if (_extensions.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(16),
),
child: Text(
context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
if (_extensions.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: SettingsGroup(
margin: EdgeInsets.zero,
children: List.generate(_extensions.length, (index) {
final extension = _extensions[index];
final isSelected = _selectedExtensionIds.contains(
extension.id,
);
return SettingsSwitchItem(
icon: Icons.extension_rounded,
title: extension.displayName,
subtitle: extension.id,
value: isSelected,
showDivider: index != _extensions.length - 1,
onChanged: (value) {
setState(() {
if (value) {
_selectedExtensionIds.add(extension.id);
} else {
_selectedExtensionIds.remove(extension.id);
}
_hasChanges = true;
});
},
);
}),
),
),
),
if (_extensions.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
context.l10n.providerPriorityFallbackExtensionsHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
Future<bool> _confirmDiscard(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogDiscardChanges),
content: Text(context.l10n.dialogUnsavedChanges),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogDiscard),
),
],
),
);
return result ?? false;
}
void _saveChanges() {
final allExtensionIds = _extensions
.map((extension) => extension.id)
.toList();
final selectedExtensionIds = allExtensionIds
.where(_selectedExtensionIds.contains)
.toList();
final fallbackExtensionIds =
selectedExtensionIds.length == allExtensionIds.length
? null
: selectedExtensionIds;
ref
.read(settingsProvider.notifier)
.setDownloadFallbackExtensionIds(fallbackExtensionIds);
setState(() {
_hasChanges = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarProviderPrioritySaved)),
);
}
}
@@ -24,7 +24,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
} }
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> { class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'deezer']; static const _builtInServices = ['tidal', 'qobuz'];
static const _songLinkRegions = [ static const _songLinkRegions = [
'AD', 'AD',
'AE', 'AE',
@@ -2053,7 +2053,7 @@ class _ServiceSelector extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider); final extState = ref.watch(extensionProvider);
final builtInServiceIds = ['tidal', 'qobuz', 'deezer']; final builtInServiceIds = ['tidal', 'qobuz'];
final extensionProviders = extState.extensions final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider) .where((e) => e.enabled && e.hasDownloadProvider)
+70 -1
View File
@@ -8,9 +8,10 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/explore_provider.dart'; import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/download_fallback_extensions_page.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart'; import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart'; import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -151,6 +152,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
child: SettingsGroup( child: SettingsGroup(
children: [ children: [
_DownloadPriorityItem(), _DownloadPriorityItem(),
_DownloadFallbackItem(),
_MetadataPriorityItem(), _MetadataPriorityItem(),
_SearchProviderSelector(), _SearchProviderSelector(),
_HomeFeedProviderSelector(), _HomeFeedProviderSelector(),
@@ -588,6 +590,73 @@ class _MetadataPriorityItem extends ConsumerWidget {
} }
} }
class _DownloadFallbackItem extends ConsumerWidget {
const _DownloadFallbackItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final hasDownloadExtensions = extState.extensions.any(
(e) => e.enabled && e.hasDownloadProvider,
);
return InkWell(
onTap: hasDownloadExtensions
? () => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => const DownloadFallbackExtensionsPage(),
),
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.alt_route,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.extensionsFallbackTitle,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasDownloadExtensions ? null : colorScheme.outline,
),
),
const SizedBox(height: 2),
Text(
hasDownloadExtensions
? context.l10n.extensionsFallbackSubtitle
: context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
],
),
),
);
}
}
class _SearchProviderSelector extends ConsumerWidget { class _SearchProviderSelector extends ConsumerWidget {
const _SearchProviderSelector(); const _SearchProviderSelector();
+210 -96
View File
@@ -24,6 +24,7 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
final _log = AppLogger('TrackMetadata'); final _log = AppLogger('TrackMetadata');
@@ -41,12 +42,35 @@ class _EmbeddedCoverPreviewCacheEntry {
class TrackMetadataScreen extends ConsumerStatefulWidget { class TrackMetadataScreen extends ConsumerStatefulWidget {
final DownloadHistoryItem? item; final DownloadHistoryItem? item;
final LocalLibraryItem? localItem; final LocalLibraryItem? localItem;
final List<DownloadHistoryItem>? historyNavigationItems;
final List<LocalLibraryItem>? localNavigationItems;
final int? navigationIndex;
const TrackMetadataScreen({super.key, this.item, this.localItem}) const TrackMetadataScreen({
: assert( super.key,
item != null || localItem != null, this.item,
'Either item or localItem must be provided', this.localItem,
); this.historyNavigationItems,
this.localNavigationItems,
this.navigationIndex,
}) : assert(
item != null || localItem != null,
'Either item or localItem must be provided',
),
assert(
historyNavigationItems == null || localNavigationItems == null,
'Provide only one navigation list type',
),
assert(
navigationIndex == null ||
((historyNavigationItems != null &&
navigationIndex >= 0 &&
navigationIndex < historyNavigationItems.length) ||
(localNavigationItems != null &&
navigationIndex >= 0 &&
navigationIndex < localNavigationItems.length)),
'navigationIndex must be within the provided navigation list',
);
@override @override
ConsumerState<TrackMetadataScreen> createState() => ConsumerState<TrackMetadataScreen> createState() =>
@@ -74,6 +98,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _isConverting = false; bool _isConverting = false;
bool _hasMetadataChanges = false; bool _hasMetadataChanges = false;
bool _hasLoadedResolvedAudioMetadata = false; bool _hasLoadedResolvedAudioMetadata = false;
bool _isTrackSwipeNavigationInFlight = false;
Map<String, dynamic>? _editedMetadata; Map<String, dynamic>? _editedMetadata;
String? _embeddedCoverPreviewPath; String? _embeddedCoverPreviewPath;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@@ -327,15 +352,25 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// Resolve label/copyright from file when the model doesn't carry them // Resolve label/copyright from file when the model doesn't carry them
// (e.g. local library items, or download history items without these fields). // (e.g. local library items, or download history items without these fields).
final resolvedTrackNumber = _readPositiveInt(metadata['track_number']);
final resolvedTotalTracks = _readPositiveInt(metadata['total_tracks']); final resolvedTotalTracks = _readPositiveInt(metadata['total_tracks']);
final resolvedDiscNumber = _readPositiveInt(metadata['disc_number']);
final resolvedTotalDiscs = _readPositiveInt(metadata['total_discs']); final resolvedTotalDiscs = _readPositiveInt(metadata['total_discs']);
final resolvedComposer = metadata['composer']?.toString(); final resolvedComposer = metadata['composer']?.toString();
final resolvedLabel = metadata['label']?.toString(); final resolvedLabel = metadata['label']?.toString();
final resolvedCopyright = metadata['copyright']?.toString(); final resolvedCopyright = metadata['copyright']?.toString();
final needsTrackNumber =
resolvedTrackNumber != null &&
resolvedTrackNumber > 0 &&
trackNumber == null;
final needsTotalTracks = final needsTotalTracks =
resolvedTotalTracks != null && resolvedTotalTracks != null &&
resolvedTotalTracks > 0 && resolvedTotalTracks > 0 &&
totalTracks == null; totalTracks == null;
final needsDiscNumber =
resolvedDiscNumber != null &&
resolvedDiscNumber > 0 &&
discNumber == null;
final needsTotalDiscs = final needsTotalDiscs =
resolvedTotalDiscs != null && resolvedTotalDiscs != null &&
resolvedTotalDiscs > 0 && resolvedTotalDiscs > 0 &&
@@ -357,13 +392,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
!_isLocalItem && !_isLocalItem &&
(resolvedBitDepth != null || (resolvedBitDepth != null ||
resolvedSampleRate != null || resolvedSampleRate != null ||
needsTrackNumber ||
needsTotalTracks ||
needsDiscNumber ||
needsTotalDiscs ||
needsComposer ||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null)); (isPlaceholderQualityLabel(_quality) && resolvedQuality != null));
if ((resolvedBitDepth != null || if ((resolvedBitDepth != null ||
resolvedSampleRate != null || resolvedSampleRate != null ||
needsAlbum || needsAlbum ||
needsDuration || needsDuration ||
needsTrackNumber ||
needsTotalTracks || needsTotalTracks ||
needsDiscNumber ||
needsTotalDiscs || needsTotalDiscs ||
needsComposer || needsComposer ||
needsLabel || needsLabel ||
@@ -379,7 +421,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate, if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
if (needsAlbum) 'album': resolvedAlbum, if (needsAlbum) 'album': resolvedAlbum,
if (needsDuration) 'duration': resolvedDuration, if (needsDuration) 'duration': resolvedDuration,
if (needsTrackNumber) 'track_number': resolvedTrackNumber,
if (needsTotalTracks) 'total_tracks': resolvedTotalTracks, if (needsTotalTracks) 'total_tracks': resolvedTotalTracks,
if (needsDiscNumber) 'disc_number': resolvedDiscNumber,
if (needsTotalDiscs) 'total_discs': resolvedTotalDiscs, if (needsTotalDiscs) 'total_discs': resolvedTotalDiscs,
if (needsComposer) 'composer': resolvedComposer, if (needsComposer) 'composer': resolvedComposer,
if (needsLabel) 'label': resolvedLabel, if (needsLabel) 'label': resolvedLabel,
@@ -396,6 +440,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
quality: resolvedQuality, quality: resolvedQuality,
bitDepth: resolvedBitDepth, bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate, sampleRate: resolvedSampleRate,
trackNumber: needsTrackNumber ? resolvedTrackNumber : null,
totalTracks: needsTotalTracks ? resolvedTotalTracks : null,
discNumber: needsDiscNumber ? resolvedDiscNumber : null,
totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null,
composer: needsComposer ? resolvedComposer : null,
); );
} }
} catch (e) { } catch (e) {
@@ -468,6 +517,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool get _isLocalItem => widget.localItem != null; bool get _isLocalItem => widget.localItem != null;
DownloadHistoryItem? get _downloadItem => widget.item; DownloadHistoryItem? get _downloadItem => widget.item;
LocalLibraryItem? get _localLibraryItem => widget.localItem; LocalLibraryItem? get _localLibraryItem => widget.localItem;
bool get _hasHistoryNavigation =>
widget.historyNavigationItems != null && widget.navigationIndex != null;
bool get _hasLocalNavigation =>
widget.localNavigationItems != null && widget.navigationIndex != null;
bool get _hasTrackSwipeNavigation =>
_hasHistoryNavigation || _hasLocalNavigation;
int? get _navigationIndex => widget.navigationIndex;
int get _navigationLength =>
widget.historyNavigationItems?.length ??
widget.localNavigationItems?.length ??
0;
String get _itemId => String get _itemId =>
_isLocalItem ? _localLibraryItem!.id : _downloadItem!.id; _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id;
@@ -505,7 +565,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
int? get totalTracks => int? get totalTracks =>
_readPositiveInt(_editedMetadata?['total_tracks']) ?? _readPositiveInt(_editedMetadata?['total_tracks']) ??
(_isLocalItem ? _localLibraryItem!.totalTracks : null); (_isLocalItem
? _localLibraryItem!.totalTracks
: _downloadItem!.totalTracks);
int? get discNumber { int? get discNumber {
final edited = _editedMetadata?['disc_number']; final edited = _editedMetadata?['disc_number'];
@@ -520,7 +582,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
int? get totalDiscs => int? get totalDiscs =>
_readPositiveInt(_editedMetadata?['total_discs']) ?? _readPositiveInt(_editedMetadata?['total_discs']) ??
(_isLocalItem ? _localLibraryItem!.totalDiscs : null); (_isLocalItem
? _localLibraryItem!.totalDiscs
: _downloadItem!.totalDiscs);
String? get releaseDate => String? get releaseDate =>
_editedMetadata?['date']?.toString() ?? _editedMetadata?['date']?.toString() ??
@@ -777,118 +841,165 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Navigator.pop(context, _hasMetadataChanges ? true : null); Navigator.pop(context, _hasMetadataChanges ? true : null);
} }
void _handleHorizontalDragEnd(DragEndDetails details) {
final velocity = details.primaryVelocity;
if (velocity == null || velocity.abs() < 350) return;
if (velocity < 0) {
unawaited(_navigateToAdjacentTrack(1));
} else {
unawaited(_navigateToAdjacentTrack(-1));
}
}
Future<void> _navigateToAdjacentTrack(int offset) async {
if (_isTrackSwipeNavigationInFlight || !_hasTrackSwipeNavigation) return;
final currentIndex = _navigationIndex;
if (currentIndex == null) return;
final targetIndex = currentIndex + offset;
if (targetIndex < 0 || targetIndex >= _navigationLength) return;
_isTrackSwipeNavigationInFlight = true;
final result = await Navigator.of(context).push<bool>(
adjacentHorizontalPageRoute<bool>(
page: _buildSiblingTrackScreen(targetIndex),
fromRight: offset > 0,
),
);
if (!mounted) return;
Navigator.pop(context, result == true || _hasMetadataChanges ? true : null);
}
TrackMetadataScreen _buildSiblingTrackScreen(int targetIndex) {
if (_hasHistoryNavigation) {
return TrackMetadataScreen(
item: widget.historyNavigationItems![targetIndex],
historyNavigationItems: widget.historyNavigationItems,
navigationIndex: targetIndex,
);
}
return TrackMetadataScreen(
localItem: widget.localNavigationItems![targetIndex],
localNavigationItems: widget.localNavigationItems,
navigationIndex: targetIndex,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final expandedHeight = _calculateExpandedHeight(context); final expandedHeight = _calculateExpandedHeight(context);
return Scaffold( return GestureDetector(
body: CustomScrollView( behavior: HitTestBehavior.translucent,
controller: _scrollController, onHorizontalDragEnd: _handleHorizontalDragEnd,
slivers: [ child: Scaffold(
SliverAppBar( body: CustomScrollView(
expandedHeight: expandedHeight, controller: _scrollController,
pinned: true, slivers: [
stretch: true, SliverAppBar(
backgroundColor: colorScheme.surface, expandedHeight: expandedHeight,
surfaceTintColor: Colors.transparent, pinned: true,
title: AnimatedOpacity( stretch: true,
duration: const Duration(milliseconds: 200), backgroundColor: colorScheme.surface,
opacity: _showTitleInAppBar ? 1.0 : 0.0, surfaceTintColor: Colors.transparent,
child: Text( title: AnimatedOpacity(
trackName, duration: const Duration(milliseconds: 200),
style: TextStyle( opacity: _showTitleInAppBar ? 1.0 : 0.0,
color: colorScheme.onSurface, child: Text(
fontWeight: FontWeight.w600, trackName,
fontSize: 16, style: TextStyle(
), color: colorScheme.onSurface,
maxLines: 1, fontWeight: FontWeight.w600,
overflow: TextOverflow.ellipsis, fontSize: 16,
),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: _buildHeaderBackground(
context,
colorScheme,
expandedHeight,
showContent,
), ),
stretchModes: const [StretchMode.zoomBackground], maxLines: 1,
); overflow: TextOverflow.ellipsis,
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
), ),
child: const Icon(Icons.arrow_back, color: Colors.white),
), ),
onPressed: _popWithMetadataResult, flexibleSpace: LayoutBuilder(
), builder: (context, constraints) {
actions: [ final collapseRatio =
IconButton( (constraints.maxHeight - kToolbarHeight) /
tooltip: MaterialLocalizations.of(context).showMenuTooltip, (expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: _buildHeaderBackground(
context,
colorScheme,
expandedHeight,
showContent,
),
stretchModes: const [StretchMode.zoomBackground],
);
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4), color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon(Icons.more_vert, color: Colors.white), child: const Icon(Icons.arrow_back, color: Colors.white),
), ),
onPressed: () => _showOptionsMenu(context, ref, colorScheme), onPressed: _popWithMetadataResult,
), ),
], actions: [
), IconButton(
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
SliverToBoxAdapter( icon: Container(
child: Padding( padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(16), decoration: BoxDecoration(
child: Column( color: Colors.black.withValues(alpha: 0.4),
crossAxisAlignment: CrossAxisAlignment.start, shape: BoxShape.circle,
children: [ ),
_buildMetadataCard(context, colorScheme, _fileSize), child: const Icon(Icons.more_vert, color: Colors.white),
const SizedBox(height: 16),
_buildFileInfoCard(
context,
colorScheme,
_fileExists,
_fileSize,
), ),
onPressed: () => _showOptionsMenu(context, ref, colorScheme),
),
],
),
const SizedBox(height: 16), SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMetadataCard(context, colorScheme, _fileSize),
_buildLyricsCard(context, colorScheme),
if (_fileExists) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
AudioAnalysisCard(filePath: _filePath),
_buildFileInfoCard(
context,
colorScheme,
_fileExists,
_fileSize,
),
const SizedBox(height: 16),
_buildLyricsCard(context, colorScheme),
if (_fileExists) ...[
const SizedBox(height: 16),
AudioAnalysisCard(filePath: _filePath),
],
const SizedBox(height: 24),
_buildActionButtons(context, ref, colorScheme, _fileExists),
const SizedBox(height: 32),
], ],
),
const SizedBox(height: 24),
_buildActionButtons(context, ref, colorScheme, _fileExists),
const SizedBox(height: 32),
],
), ),
), ),
), ],
], ),
), ),
); );
} }
@@ -2767,9 +2878,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
albumArtist: normalizedOrNull(albumArtist), albumArtist: normalizedOrNull(albumArtist),
isrc: normalizedOrNull(isrc), isrc: normalizedOrNull(isrc),
trackNumber: trackNumber, trackNumber: trackNumber,
totalTracks: totalTracks,
discNumber: discNumber, discNumber: discNumber,
totalDiscs: totalDiscs,
releaseDate: normalizedOrNull(releaseDate), releaseDate: normalizedOrNull(releaseDate),
genre: normalizedOrNull(genre), genre: normalizedOrNull(genre),
composer: normalizedOrNull(composer),
label: normalizedOrNull(label), label: normalizedOrNull(label),
copyright: normalizedOrNull(copyright), copyright: normalizedOrNull(copyright),
); );
+157 -7
View File
@@ -13,6 +13,95 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg'); 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 { class FFmpegService {
static const int _commandLogPreviewLength = 300; static const int _commandLogPreviewLength = 300;
static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8); static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8);
@@ -22,6 +111,7 @@ class FFmpegService {
static const Duration _liveTunnelStabilizationDelay = Duration( static const Duration _liveTunnelStabilizationDelay = Duration(
milliseconds: 900, milliseconds: 900,
); );
static const String _genericMovKeyDecryptionStrategy = 'ffmpeg.mov_key';
static int _tempEmbedCounter = 0; static int _tempEmbedCounter = 0;
static FFmpegSession? _activeLiveDecryptSession; static FFmpegSession? _activeLiveDecryptSession;
static String? _activeLiveDecryptUrl; static String? _activeLiveDecryptUrl;
@@ -216,12 +306,56 @@ class FFmpegService {
required String decryptionKey, required String decryptionKey,
bool deleteOriginal = true, bool deleteOriginal = true,
}) async { }) async {
final trimmedKey = decryptionKey.trim(); return decryptWithDescriptor(
if (trimmedKey.isEmpty) return inputPath; inputPath: inputPath,
descriptor: DownloadDecryptionDescriptor(
strategy: _genericMovKeyDecryptionStrategy,
key: decryptionKey,
inputFormat: 'mov',
),
deleteOriginal: deleteOriginal,
);
}
// Encrypted streams are commonly MP4 container with FLAC audio. static Future<String?> decryptWithDescriptor({
// Prefer FLAC output to avoid MP4 muxing errors during decrypt copy. required String inputPath,
final preferredExt = inputPath.toLowerCase().endsWith('.m4a') 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' ? '.flac'
: inputPath.toLowerCase().endsWith('.flac') : inputPath.toLowerCase().endsWith('.flac')
? '.flac' ? '.flac'
@@ -230,7 +364,23 @@ class FFmpegService {
: inputPath.toLowerCase().endsWith('.opus') : inputPath.toLowerCase().endsWith('.opus')
? '.opus' ? '.opus'
: '.flac'; : '.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); var tempOutput = _buildOutputPath(inputPath, preferredExt);
final demuxerFormat = (inputFormat ?? '').trim().isNotEmpty
? inputFormat!.trim()
: 'mov';
String buildDecryptCommand( String buildDecryptCommand(
String outputPath, { String outputPath, {
@@ -241,10 +391,10 @@ class FFmpegService {
// Force MOV demuxer: -decryption_key is only supported by the MOV/MP4 // Force MOV demuxer: -decryption_key is only supported by the MOV/MP4
// demuxer. The input may carry a .flac extension (SAF mode) while actually // demuxer. The input may carry a .flac extension (SAF mode) while actually
// containing an encrypted M4A stream, so we must override auto-detection. // 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) { if (keyCandidates.isEmpty) {
_log.e('No usable decryption key candidates'); _log.e('No usable decryption key candidates');
return null; return null;
+35 -1
View File
@@ -31,7 +31,7 @@ class HistoryDatabase {
return await openDatabase( return await openDatabase(
path, path,
version: 3, version: 5,
onConfigure: (db) async { onConfigure: (db) async {
await db.rawQuery('PRAGMA journal_mode = WAL'); await db.rawQuery('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL'); await db.execute('PRAGMA synchronous = NORMAL');
@@ -63,13 +63,16 @@ class HistoryDatabase {
isrc TEXT, isrc TEXT,
spotify_id TEXT, spotify_id TEXT,
track_number INTEGER, track_number INTEGER,
total_tracks INTEGER,
disc_number INTEGER, disc_number INTEGER,
total_discs INTEGER,
duration INTEGER, duration INTEGER,
release_date TEXT, release_date TEXT,
quality TEXT, quality TEXT,
bit_depth INTEGER, bit_depth INTEGER,
sample_rate INTEGER, sample_rate INTEGER,
genre TEXT, genre TEXT,
composer TEXT,
label TEXT, label TEXT,
copyright TEXT copyright TEXT
) )
@@ -98,6 +101,31 @@ class HistoryDatabase {
if (oldVersion < 3) { if (oldVersion < 3) {
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER'); await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
} }
if (oldVersion < 4) {
final columns = await db.rawQuery('PRAGMA table_info(history)');
final hasComposer = columns.any(
(row) => (row['name']?.toString().toLowerCase() ?? '') == 'composer',
);
if (!hasComposer) {
await db.execute('ALTER TABLE history ADD COLUMN composer TEXT');
}
}
if (oldVersion < 5) {
final columns = await db.rawQuery('PRAGMA table_info(history)');
final hasTotalTracks = columns.any(
(row) =>
(row['name']?.toString().toLowerCase() ?? '') == 'total_tracks',
);
final hasTotalDiscs = columns.any(
(row) => (row['name']?.toString().toLowerCase() ?? '') == 'total_discs',
);
if (!hasTotalTracks) {
await db.execute('ALTER TABLE history ADD COLUMN total_tracks INTEGER');
}
if (!hasTotalDiscs) {
await db.execute('ALTER TABLE history ADD COLUMN total_discs INTEGER');
}
}
} }
static final _iosContainerPattern = RegExp( static final _iosContainerPattern = RegExp(
@@ -258,13 +286,16 @@ class HistoryDatabase {
'isrc': json['isrc'], 'isrc': json['isrc'],
'spotify_id': json['spotifyId'], 'spotify_id': json['spotifyId'],
'track_number': json['trackNumber'], 'track_number': json['trackNumber'],
'total_tracks': json['totalTracks'],
'disc_number': json['discNumber'], 'disc_number': json['discNumber'],
'total_discs': json['totalDiscs'],
'duration': json['duration'], 'duration': json['duration'],
'release_date': json['releaseDate'], 'release_date': json['releaseDate'],
'quality': json['quality'], 'quality': json['quality'],
'bit_depth': json['bitDepth'], 'bit_depth': json['bitDepth'],
'sample_rate': json['sampleRate'], 'sample_rate': json['sampleRate'],
'genre': json['genre'], 'genre': json['genre'],
'composer': json['composer'],
'label': json['label'], 'label': json['label'],
'copyright': json['copyright'], 'copyright': json['copyright'],
}; };
@@ -289,13 +320,16 @@ class HistoryDatabase {
'isrc': row['isrc'], 'isrc': row['isrc'],
'spotifyId': row['spotify_id'], 'spotifyId': row['spotify_id'],
'trackNumber': row['track_number'], 'trackNumber': row['track_number'],
'totalTracks': row['total_tracks'],
'discNumber': row['disc_number'], 'discNumber': row['disc_number'],
'totalDiscs': row['total_discs'],
'duration': row['duration'], 'duration': row['duration'],
'releaseDate': row['release_date'], 'releaseDate': row['release_date'],
'quality': row['quality'], 'quality': row['quality'],
'bitDepth': row['bit_depth'], 'bitDepth': row['bit_depth'],
'sampleRate': row['sample_rate'], 'sampleRate': row['sample_rate'],
'genre': row['genre'], 'genre': row['genre'],
'composer': row['composer'],
'label': row['label'], 'label': row['label'],
'copyright': row['copyright'], 'copyright': row['copyright'],
}; };
+9
View File
@@ -781,6 +781,15 @@ class PlatformBridge {
return list.map((e) => e as String).toList(); return list.map((e) => e as String).toList();
} }
static Future<void> setDownloadFallbackExtensionIds(
List<String>? extensionIds,
) async {
_log.d('setDownloadFallbackExtensionIds: $extensionIds');
await _channel.invokeMethod('setDownloadFallbackExtensionIds', {
'extension_ids': extensionIds == null ? '' : jsonEncode(extensionIds),
});
}
static Future<void> setMetadataProviderPriority( static Future<void> setMetadataProviderPriority(
List<String> providerIds, List<String> providerIds,
) async { ) async {
+29
View File
@@ -93,6 +93,35 @@ Route<T> slidePageRoute<T>({required Widget page}) {
return MaterialPageRoute<T>(builder: (context) => page); return MaterialPageRoute<T>(builder: (context) => page);
} }
/// A directional horizontal transition for adjacent content, such as moving
/// between next/previous items within the same detail context.
Route<T> adjacentHorizontalPageRoute<T>({
required Widget page,
required bool fromRight,
}) {
final begin = Offset(fromRight ? 0.22 : -0.22, 0);
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 240),
reverseTransitionDuration: const Duration(milliseconds: 220),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return SlideTransition(
position: Tween<Offset>(begin: begin, end: Offset.zero).animate(curved),
child: FadeTransition(
opacity: Tween<double>(begin: 0.92, end: 1.0).animate(curved),
child: child,
),
);
},
);
}
/// A shimmer effect widget that can wrap skeleton placeholders. /// A shimmer effect widget that can wrap skeleton placeholders.
class ShimmerLoading extends StatefulWidget { class ShimmerLoading extends StatefulWidget {
final Widget child; final Widget child;
+12 -11
View File
@@ -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 { class DownloadServicePicker extends ConsumerStatefulWidget {
@@ -138,6 +127,18 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
} else { } else {
_selectedService = ref.read(settingsProvider).defaultService; _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() { List<QualityOption> _getQualityOptions() {
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none" publish_to: "none"
version: 4.2.1+122 version: 4.2.2+123
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0