feat: move Amazon Music to extension, fix Deezer download timeout

This commit is contained in:
zarzet 2026-03-07 00:12:07 +07:00
parent 4a61ffea8d
commit 7d5cb574c6
105 changed files with 285 additions and 1440 deletions

View file

@ -1,5 +1,21 @@
# Changelog
## [3.7.2] - 2026-03-07
### Changed
- **Amazon Music is now an Extension**: Amazon Music has been moved from a built-in service to a separate installable extension. Install the "Amazon Music" extension from the Store to continue using it.
### Fixed
- **Deezer Downloads Timing Out**: Deezer downloads were failing with "context deadline exceeded" on larger files. Now uses the proper download timeout, matching Tidal and Qobuz.
### Added
- **Amazon Music Extension**: Available in `extension/Amazon-SpotiFLAC/` — same functionality as before, now as an installable extension.
---
## [3.7.1] - 2026-03-06
### Added

View file

@ -6,7 +6,7 @@
<img src="icon.png" width="128" />
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
Download music in true lossless FLAC from Tidal, Qobuz & Deezer — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
@ -51,10 +51,10 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
## FAQ
**Q: Why is my download failing with "Song not found"?**
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
**Q: Why are some tracks downloading in lower quality?**
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
A: Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz.
**Q: Can I download playlists?**
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
@ -75,23 +75,6 @@ _If this software is useful and brings you value, consider supporting the projec
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
## Disclaimer
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
The application is purely a user interface that facilitates communication between your device and existing third-party services.
You are solely responsible for:
1. Ensuring your use of this software complies with your local laws.
2. Reading and adhering to the Terms of Service of the respective platforms.
3. Any legal consequences resulting from the misuse of this tool.
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
## API Credits
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)

View file

@ -766,6 +766,27 @@ class MainActivity: FlutterFragmentActivity() {
val response = downloader(req.toString())
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
// Extension providers write to a local temp path instead of the SAF FD.
// Copy the local file into the SAF document so it is not empty.
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
!goFilePath.startsWith("/proc/self/fd/")
) {
try {
val srcFile = java.io.File(goFilePath)
if (srcFile.exists() && srcFile.length() > 0) {
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
}
}
srcFile.delete()
}
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}")
}
}
respObj.put("file_path", document.uri.toString())
respObj.put("file_name", document.name ?: fileName)
} else {
@ -2239,13 +2260,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getAmazonURLFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
// Log methods
"getLogs" -> {
val response = withContext(Dispatchers.IO) {

View file

@ -1,692 +0,0 @@
package gobackend
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
// Amazon API timeout and retry configuration for mobile networks
const (
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
amazonMaxRetries = 2 // Number of retry attempts
amazonRetryDelay = 500 * time.Millisecond
)
type AmazonDownloader struct {
client *http.Client
}
var (
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
)
// AfkarXYZResponse is the response from AfkarXYZ API
type AfkarXYZResponse struct {
Success bool `json:"success"`
Data struct {
DirectLink string `json:"direct_link"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
} `json:"data"`
}
// AmazonStreamResponse is the new response format from amzn.afkarxyz.fun/api/track/{asin}
type AmazonStreamResponse struct {
StreamURL string `json:"streamUrl"`
DecryptionKey string `json:"decryptionKey"`
}
func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second),
}
})
return globalAmazonDownloader
}
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
// Returns downloadURL, suggested fileName, optional decryptionKey.
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
var lastErr error
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
if attempt > 0 {
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
time.Sleep(delay)
}
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
if err == nil {
return downloadURL, fileName, decryptionKey, nil
}
lastErr = err
errStr := strings.ToLower(err.Error())
// Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "eof") ||
strings.Contains(errStr, "status 5") ||
strings.Contains(errStr, "status 429") ||
strings.Contains(errStr, "http 429")
if !isRetryable {
return "", "", "", err
}
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
}
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
}
func normalizeAmazonASIN(candidate string) string {
trimmed := strings.TrimSpace(candidate)
if trimmed == "" {
return ""
}
if decoded, err := url.QueryUnescape(trimmed); err == nil {
trimmed = decoded
}
trimmed = strings.ToUpper(trimmed)
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
trimmed = trimmed[:idx]
}
if amazonASINRegex.MatchString(trimmed) {
return trimmed
}
return ""
}
func extractAmazonASIN(amazonURL string) string {
raw := strings.TrimSpace(amazonURL)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err == nil {
query := parsed.Query()
// Prefer track-level ASIN when URL also contains albumAsin.
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
return asin
}
}
path := strings.Trim(parsed.Path, "/")
if path != "" {
segments := strings.Split(path, "/")
for i := 0; i < len(segments)-1; i++ {
segment := strings.ToLower(strings.TrimSpace(segments[i]))
if segment == "track" || segment == "tracks" {
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
return asin
}
}
}
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
return asin
}
}
}
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
return normalizeAmazonASIN(match)
}
// doAfkarXYZRequest performs a single request to Amazon API.
// It tries new endpoint first, then falls back to legacy /convert endpoint.
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
asin := extractAmazonASIN(amazonURL)
if asin != "" {
GoLog("[Amazon] Using ASIN: %s\n", asin)
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
if err == nil {
return downloadURL, fileName, decryptKey, nil
}
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
}
return a.doAfkarXYZRequestLegacy(amazonURL)
}
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
defer cancel()
apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", "", "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
resp, err := a.client.Do(req)
if err != nil {
return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err)
}
defer resp.Body.Close()
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return "", "", "", fmt.Errorf("failed to read response: %w", readErr)
}
if resp.StatusCode != 200 {
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
}
var apiResp AmazonStreamResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return "", "", "", fmt.Errorf("failed to decode response: %w", err)
}
if strings.TrimSpace(apiResp.StreamURL) == "" {
return "", "", "", fmt.Errorf("Amazon API returned empty stream URL")
}
fileName := asin + ".m4a"
return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil
}
func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
defer cancel()
apiURL := "https://amzn.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := a.client.Do(req)
if err != nil {
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
}
var apiResp AfkarXYZResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
}
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
}
fileName := apiResp.Data.FileName
if fileName == "" {
fileName = "track.flac"
}
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "")
return apiResp.Data.DirectLink, fileName, "", nil
}
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
if err != nil {
return "", "", "", err
}
if decryptionKey != "" {
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
}
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
return downloadURL, fileName, decryptionKey, nil
}
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
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 request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := a.client.Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed: 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 buffer: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
return nil
}
// AmazonDownloadResult contains download result with quality info
type AmazonDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
DecryptionKey string
}
func resolveAmazonURLForRequest(req DownloadRequest, logPrefix string) (string, error) {
if strings.TrimSpace(logPrefix) == "" {
logPrefix = "Amazon"
}
amazonURL := ""
if req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
amazonURL = cached.AmazonURL
GoLog("[%s] Cache hit! Using cached Amazon URL for ISRC %s\n", logPrefix, req.ISRC)
}
}
if amazonURL != "" {
return amazonURL, nil
}
songlink := NewSongLinkClient()
var availability *TrackAvailability
var err error
deezerID := strings.TrimSpace(req.DeezerID)
if prefixedDeezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found && strings.TrimSpace(prefixedDeezerID) != "" {
deezerID = strings.TrimSpace(prefixedDeezerID)
}
if deezerID != "" {
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" {
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
} else {
return "", fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
}
if err != nil {
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
}
if availability == nil || !availability.Amazon || availability.AmazonURL == "" {
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
amazonURL = availability.AmazonURL
if req.ISRC != "" {
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
}
return amazonURL, nil
}
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
amazonURL, err := resolveAmazonURLForRequest(req, "Amazon")
if err != nil {
return AmazonDownloadResult{}, err
}
if !isSafOutput && req.OutputDir != "." {
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
}
}
// Download using AfkarXYZ API
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
}
GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
"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 {
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
if outputExt == "" {
outputExt = ".flac"
}
filename = sanitizeFilename(filename) + outputExt
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
}
// START PARALLEL: Fetch cover and lyrics while downloading audio
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),
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return AmazonDownloadResult{}, ErrDownloadCancelled
}
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
actualOutputPath := outputPath
needsDecryption := strings.TrimSpace(decryptionKey) != ""
if needsDecryption {
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
}
// Wait for parallel operations to complete
<-parallelDone
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
actualDate := req.ReleaseDate
actualAlbum := req.AlbumName
actualTitle := req.TrackName
actualArtist := req.ArtistName
if !needsDecryption {
existingMeta, metaErr := ReadMetadata(actualOutputPath)
if metaErr == nil && existingMeta != nil {
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
}
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
actualDiscNum = existingMeta.DiscNumber
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
}
if existingMeta.Date != "" && req.ReleaseDate == "" {
actualDate = existingMeta.Date
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
}
if existingMeta.Album != "" && req.AlbumName == "" {
actualAlbum = existingMeta.Album
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
}
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
}
}
metadata := Metadata{
Title: actualTitle,
Artist: actualArtist,
Album: actualAlbum,
AlbumArtist: req.AlbumArtist,
Date: actualDate,
TrackNumber: actualTrackNum,
TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
coverData = parallelResult.CoverData
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} else {
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
if coverErr == nil && len(existingCover) > 0 {
coverData = existingCover
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
} else {
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
}
}
if isSafOutput || needsDecryption || !req.EmbedMetadata {
if !req.EmbedMetadata {
GoLog("[Amazon] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
} else {
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
}
} else {
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
if isFlacOutput {
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
}
} else {
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
}
}
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
GoLog("[Amazon] Lyrics embedded successfully\n")
}
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
}
} else if req.EmbedLyrics {
GoLog("[Amazon] No lyrics available from parallel fetch\n")
}
}
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
quality := AudioQuality{}
if isSafOutput || needsDecryption {
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
} else {
quality, err = GetAudioQuality(actualOutputPath)
if err != nil {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
} else {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
actualTrackNum = finalMeta.TrackNumber
actualDiscNum = finalMeta.DiscNumber
if finalMeta.Date != "" {
req.ReleaseDate = finalMeta.Date
}
}
}
// Add to ISRC index for fast duplicate checking.
// When decryption is pending in Flutter, postpone indexing until final file is settled.
if !isSafOutput && !needsDecryption {
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
}
bitDepth := 0
sampleRate := 0
if err == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
}
lyricsLRC := ""
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
return AmazonDownloadResult{
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: actualTrackNum,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
DecryptionKey: decryptionKey,
}, nil
}

View file

@ -1,46 +0,0 @@
package gobackend
import "testing"
func TestExtractAmazonASIN(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{
name: "prefers trackAsin over albumAsin",
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
want: "B0TRACK456",
},
{
name: "extract from tracks path",
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
want: "B0CYQHGWZJ",
},
{
name: "extract from plain query asin",
url: "https://example.com/?asin=B0CYQHGWZJ",
want: "B0CYQHGWZJ",
},
{
name: "fallback regex",
url: "https://example.com/path/B0CYQHGWZJ",
want: "B0CYQHGWZJ",
},
{
name: "invalid url",
url: "https://music.amazon.com/tracks/not-valid",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractAmazonASIN(tt.url)
if got != tt.want {
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
}
})
}
}

View file

@ -12,7 +12,6 @@ import (
"strings"
)
// AudioMetadata represents common audio file metadata
type AudioMetadata struct {
Title string
Artist string
@ -31,7 +30,6 @@ type AudioMetadata struct {
Comment string
}
// MP3Quality represents MP3 specific quality info
type MP3Quality struct {
SampleRate int
BitDepth int
@ -39,7 +37,6 @@ type MP3Quality struct {
Bitrate int
}
// OggQuality represents Ogg/Opus specific quality info
type OggQuality struct {
SampleRate int
BitDepth int
@ -47,10 +44,6 @@ type OggQuality struct {
Bitrate int // estimated bitrate in bps
}
// =============================================================================
// ID3 Tag Reading (MP3)
// =============================================================================
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
file, err := os.Open(filePath)
if err != nil {
@ -1210,10 +1203,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
return 0
}
// =============================================================================
// ID3v1 Genre List
// =============================================================================
var id3v1Genres = []string{
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
@ -1244,10 +1233,6 @@ var id3v1Genres = []string{
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
}
// =============================================================================
// Cover Art Extraction
// =============================================================================
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
file, err := os.Open(filePath)
if err != nil {

View file

@ -120,7 +120,7 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
resp, err := GetDownloadClient().Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
@ -324,7 +324,7 @@ func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, ou
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
resp, err := GetDownloadClient().Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled

View file

@ -478,25 +478,6 @@ func DownloadTrack(requestJSON string) (string, error) {
}
}
err = qobuzErr
case "amazon":
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
}
}
err = amazonErr
case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil {
@ -640,7 +621,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
enrichRequestExtendedMetadata(&req)
allServices := []string{"tidal", "qobuz", "amazon", "deezer"}
allServices := []string{"tidal", "qobuz", "deezer"}
preferredService := req.Service
if preferredService == "" {
preferredService = "tidal"
@ -707,27 +688,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
}
err = qobuzErr
case "amazon":
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
}
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
}
err = amazonErr
case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil {
@ -1579,11 +1539,6 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
return client.GetTidalURLFromDeezer(deezerTrackID)
}
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetAmazonURLFromDeezer(deezerTrackID)
}
func errorResponse(msg string) (string, error) {
errorType := "unknown"
lowerMsg := strings.ToLower(msg)
@ -2146,8 +2101,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// ==================== EXTENSION SYSTEM ====================
func InitExtensionSystem(extensionsDir, dataDir string) error {
manager := GetExtensionManager()
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
@ -2519,8 +2472,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
return string(jsonBytes), nil
}
// ==================== EXTENSION CUSTOM SEARCH ====================
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
@ -3273,9 +3224,6 @@ func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
}
// ==================== LOCAL LIBRARY SCANNING ====================
// SetLibraryCoverCacheDirJSON sets the directory for caching extracted cover art
func SetLibraryCoverCacheDirJSON(cacheDir string) {
SetLibraryCoverCacheDir(cacheDir)
}
@ -3284,9 +3232,6 @@ func ScanLibraryFolderJSON(folderPath string) (string, error) {
return ScanLibraryFolder(folderPath)
}
// ScanLibraryFolderIncrementalJSON performs an incremental library scan
// existingFilesJSON: JSON object mapping filePath -> modTime (unix millis)
// Returns IncrementalScanResult as JSON
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
}

View file

@ -401,7 +401,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
}
// Parse and validate manifest
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
@ -467,17 +466,11 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
}
}
// Optionally remove data directory (keep for now to preserve settings)
// if ext.DataDir != "" {
// os.RemoveAll(ext.DataDir)
// }
return nil
}
// Only allows upgrades (new version > current version), not downgrades
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
@ -529,7 +522,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
}
// Compare versions - only allow upgrade, not downgrade
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
if versionCompare < 0 {
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
@ -540,7 +532,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
// Save data directory path and enabled state (we want to preserve them)
extDataDir := existing.DataDir
extDir := existing.SourceDir
wasEnabled := existing.Enabled
@ -601,7 +592,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
SourceDir: extDir,
}
// Initialize Goja VM
if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
@ -626,7 +616,6 @@ type ExtensionUpgradeInfo struct {
}
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
@ -675,7 +664,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
}
if !exists {
// Not installed - this is a new install, not upgrade
info.CurrentVersion = ""
info.CanUpgrade = false
} else {
@ -739,7 +727,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
permissions = append(permissions, "storage:enabled")
}
// Determine status
status := "loaded"
if ext.Error != "" {
status = "error"
@ -940,7 +927,6 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
return nil, fmt.Errorf("extension is disabled")
}
// Call the action function on the extension object
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {

View file

@ -1,4 +1,3 @@
// Package gobackend provides extension manifest parsing and validation
package gobackend
import (

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
@ -108,6 +109,7 @@ type ExtDownloadResult struct {
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ISRC string `json:"isrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
}
type ExtensionProviderWrapper struct {
@ -388,7 +390,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return &enrichedTrack, nil
}
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) {
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
}
@ -403,11 +405,11 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
return extension.checkAvailability(%q, %q, %q);
return extension.checkAvailability(%q, %q, %q, {spotify_id: %q, deezer_id: %q});
}
return null;
})()
`, isrc, trackName, artistName)
`, isrc, trackName, artistName, spotifyID, deezerID)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
@ -631,7 +633,7 @@ func GetProviderPriority() []string {
defer providerPriorityMu.RUnlock()
if len(providerPriority) == 0 {
return []string{"tidal", "qobuz", "amazon", "deezer"}
return []string{"tidal", "qobuz", "deezer"}
}
result := make([]string, len(providerPriority))
@ -661,7 +663,7 @@ func GetMetadataProviderPriority() []string {
func isBuiltInProvider(providerID string) bool {
switch providerID {
case "tidal", "qobuz", "amazon", "deezer":
case "tidal", "qobuz", "deezer":
return true
default:
return false
@ -694,6 +696,27 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
priority = newPriority
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
} else if !strictMode && req.Service != "" && !isBuiltInProvider(strings.ToLower(req.Service)) {
found := false
for _, p := range priority {
if strings.EqualFold(p, req.Service) {
found = true
break
}
}
newPriority := []string{req.Service}
for _, p := range priority {
if !strings.EqualFold(p, req.Service) {
newPriority = append(newPriority, p)
}
}
priority = newPriority
if !found {
GoLog("[DownloadWithExtensionFallback] Extension service '%s' added to priority front\n", req.Service)
} else {
GoLog("[DownloadWithExtensionFallback] Extension service '%s' moved to priority front\n", req.Service)
}
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
}
var lastErr error
@ -777,7 +800,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
outputPath := buildOutputPath(req)
outputPath := buildOutputPathForExtension(req, ext)
if req.ItemID != "" {
StartItemProgress(req.ItemID)
}
@ -813,6 +836,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey,
}
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
@ -966,7 +990,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
provider := NewExtensionProviderWrapper(ext)
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID)
if err != nil || !availability.Available {
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
if err != nil {
@ -975,7 +999,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue
}
outputPath := buildOutputPath(req)
outputPath := buildOutputPathForExtension(req, ext)
if req.ItemID != "" {
StartItemProgress(req.ItemID)
}
@ -1011,6 +1035,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey,
}
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
@ -1128,25 +1153,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
}
}
err = qobuzErr
case "amazon":
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
}
}
err = amazonErr
case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil {
@ -1226,7 +1232,58 @@ func buildOutputPath(req DownloadRequest) string {
ext = "." + ext
}
return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext)
outputDir := req.OutputDir
if strings.TrimSpace(outputDir) == "" {
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads")
os.MkdirAll(outputDir, 0755)
AddAllowedDownloadDir(outputDir)
}
return filepath.Join(outputDir, filename+ext)
}
func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) string {
if strings.TrimSpace(req.OutputPath) != "" {
return strings.TrimSpace(req.OutputPath)
}
if strings.TrimSpace(req.OutputDir) != "" {
return buildOutputPath(req)
}
// SAF mode: use extension's data dir as writable temp location
tempDir := filepath.Join(ext.DataDir, "downloads")
os.MkdirAll(tempDir, 0755)
AddAllowedDownloadDir(tempDir)
metadata := map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
if filename == "" {
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
}
outputExt := strings.TrimSpace(req.OutputExt)
if outputExt == "" {
outputExt = ".flac"
} else if !strings.HasPrefix(outputExt, ".") {
outputExt = "." + outputExt
}
return filepath.Join(tempDir, filename+outputExt)
}
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
@ -1653,7 +1710,6 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu
}, nil
}
// GetPostProcessingProviders returns all extensions that provide post-processing
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@ -1667,7 +1723,6 @@ func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrap
return providers
}
// RunPostProcessing runs all enabled post-processing hooks on a file
func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders()
if len(providers) == 0 {
@ -1713,7 +1768,6 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
}
// RunPostProcessingV2 runs all enabled post-processing hooks on a file input.
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders()
if len(providers) == 0 {
@ -1768,9 +1822,6 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
}
// ==================== Lyrics Provider ====================
// ExtLyricsResult represents lyrics data returned from an extension
type ExtLyricsResult struct {
Lines []ExtLyricsLine `json:"lines"`
SyncType string `json:"syncType"`
@ -1785,7 +1836,6 @@ type ExtLyricsLine struct {
EndTimeMs int64 `json:"endTimeMs"`
}
// FetchLyrics calls the extension's fetchLyrics function
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
if !p.extension.Manifest.IsLyricsProvider() {
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
@ -1885,7 +1935,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
return response, nil
}
// GetLyricsProviders returns all enabled extensions that provide lyrics
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()

View file

@ -1,4 +1,3 @@
// Package gobackend provides Auth API and PKCE support for extension runtime
package gobackend
import (
@ -16,8 +15,6 @@ import (
"github.com/dop251/goja"
)
// ==================== Auth API (OAuth Support) ====================
func validateExtensionAuthURL(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
@ -204,9 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result)
}
// ==================== PKCE Support ====================
// generatePKCEVerifier generates a cryptographically random code verifier
// Length should be between 43-128 characters (RFC 7636)
func generatePKCEVerifier(length int) (string, error) {
if length < 43 {
@ -394,9 +388,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
})
}
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
// Uses the stored PKCE verifier automatically
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@ -414,7 +406,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Required fields
tokenURL, _ := config["tokenUrl"].(string)
clientID, _ := config["clientId"].(string)
redirectURI, _ := config["redirectUri"].(string)

View file

@ -1,4 +1,3 @@
// Package gobackend provides FFmpeg API for extension runtime
package gobackend
import (
@ -10,9 +9,7 @@ import (
"github.com/dop251/goja"
)
// ==================== FFmpeg API (Post-Processing) ====================
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute.
type FFmpegCommand struct {
ExtensionID string
Command string
@ -24,7 +21,6 @@ type FFmpegCommand struct {
Output string
}
// Global FFmpeg command queue
var (
ffmpegCommands = make(map[string]*FFmpegCommand)
ffmpegCommandsMu sync.RWMutex

View file

@ -1,4 +1,3 @@
// Package gobackend provides File API for extension runtime
package gobackend
import (
@ -13,8 +12,6 @@ import (
"github.com/dop251/goja"
)
// ==================== File API (Sandboxed) ====================
var (
allowedDownloadDirs []string
allowedDownloadDirsMu sync.RWMutex

View file

@ -1,4 +1,3 @@
// Package gobackend provides HTTP API for extension runtime
package gobackend
import (
@ -12,8 +11,6 @@ import (
"github.com/dop251/goja"
)
// ==================== HTTP API (Sandboxed) ====================
type HTTPResponse struct {
StatusCode int `json:"statusCode"`
Body string `json:"body"`

View file

@ -1,4 +1,3 @@
// Package gobackend provides Track Matching API for extension runtime
package gobackend
import (
@ -7,8 +6,6 @@ import (
"github.com/dop251/goja"
)
// ==================== Track Matching API ====================
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(0.0)

View file

@ -1,4 +1,3 @@
// Package gobackend provides Browser-like Polyfills for extension runtime
package gobackend
import (
@ -13,12 +12,10 @@ import (
"github.com/dop251/goja"
)
// ==================== Browser-like Polyfills ====================
// These polyfills make porting browser/Node.js libraries easier
// without compromising sandbox security
// without compromising sandbox security.
// fetchPolyfill implements browser-compatible fetch() API
// Returns a Promise-like object with json(), text() methods
// Returns a Promise-like object with json(), text() methods.
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.createFetchError("URL is required")
@ -141,7 +138,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return responseObj
}
// createFetchError creates a fetch error response
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
errorObj := r.vm.NewObject()
errorObj.Set("ok", false)
@ -157,7 +153,6 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
return errorObj
}
// atobPolyfill implements browser atob() - decode base64 to string
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
@ -174,7 +169,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded))
}
// btoaPolyfill implements browser btoa() - encode string to base64
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
@ -183,7 +177,6 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
}
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
encoder := call.This
@ -429,9 +422,8 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
})
}
// registerJSONGlobal ensures JSON global is properly set up
// JSON is already built-in to Goja; this ensures a fallback exists.
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
// JSON is already built-in to Goja, but we can enhance it
jsonScript := `
if (typeof JSON === 'undefined') {
var JSON = {

View file

@ -1,4 +1,3 @@
// Package gobackend provides Storage and Credentials API for extension runtime
package gobackend
import (
@ -17,8 +16,6 @@ import (
"github.com/dop251/goja"
)
// ==================== Storage API ====================
const (
defaultStorageFlushDelay = 400 * time.Millisecond
storageFlushRetryDelay = 2 * time.Second

View file

@ -1,4 +1,3 @@
// Package gobackend provides Utility functions for extension runtime
package gobackend
import (
@ -17,8 +16,6 @@ import (
"github.com/dop251/goja"
)
// ==================== Utility Functions ====================
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")

View file

@ -1,4 +1,3 @@
// Package gobackend provides extension settings storage
package gobackend
import (

View file

@ -1,4 +1,3 @@
// Package gobackend provides timeout execution for extension JS code
package gobackend
import (

View file

@ -489,7 +489,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
}
}
// Check error message patterns for common ISP blocking indicators
blockingPatterns := []struct {
pattern string
reason string
@ -532,7 +531,6 @@ func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
return false
}
// extractDomain extracts the domain from a URL string
func extractDomain(rawURL string) string {
if rawURL == "" {
return "unknown"

View file

@ -91,7 +91,6 @@ func (t *utlsTransport) getPort(u *url.URL) string {
return "80"
}
// Cloudflare bypass client using uTLS Chrome fingerprint
var cloudflareBypassTransport = newUTLSTransport()
var cloudflareBypassClient = &http.Client{
@ -111,7 +110,6 @@ func GetCloudflareBypassClient() *http.Client {
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
// Try with standard client first
resp, err := sharedClient.Do(req)
if err == nil {
// Check for Cloudflare challenge page (403 with specific markers)
@ -138,11 +136,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
if isCloudflare {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
// Clone request for retry
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
// Retry with uTLS Chrome fingerprint
return cloudflareBypassClient.Do(reqCopy)
}
}
@ -168,11 +164,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
if tlsRelated {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
// Clone request for retry
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
// Retry with uTLS Chrome fingerprint
return cloudflareBypassClient.Do(reqCopy)
}

View file

@ -22,13 +22,11 @@ var (
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
)
// IDHSSearchRequest represents the request body for IDHS API
type IDHSSearchRequest struct {
Link string `json:"link"`
Adapters []string `json:"adapters,omitempty"`
}
// IDHSSearchResponse represents the response from IDHS API
type IDHSSearchResponse struct {
ID string `json:"id"`
Type string `json:"type"` // song, album, artist, podcast, show
@ -41,7 +39,6 @@ type IDHSSearchResponse struct {
Links []IDHSLink `json:"links"`
}
// IDHSLink represents a link to a streaming platform
type IDHSLink struct {
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
URL string `json:"url"`
@ -49,7 +46,6 @@ type IDHSLink struct {
NotAvailable bool `json:"notAvailable,omitempty"`
}
// NewIDHSClient creates a new IDHS client
func NewIDHSClient() *IDHSClient {
idhsClientOnce.Do(func() {
globalIDHSClient = &IDHSClient{
@ -117,7 +113,6 @@ func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
// Request only the platforms we need
adapters := []string{"tidal", "deezer"}
result, err := c.Search(spotifyURL, adapters)
@ -151,11 +146,9 @@ func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAv
return availability, nil
}
// GetAvailabilityFromDeezer checks track availability using IDHS
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
// Request only the platforms we need
adapters := []string{"spotify", "tidal"}
result, err := c.Search(deezerURL, adapters)

View file

@ -10,7 +10,6 @@ import (
"time"
)
// LibraryScanResult represents metadata from a scanned audio file
type LibraryScanResult struct {
ID string `json:"id"`
TrackName string `json:"trackName"`
@ -42,7 +41,6 @@ type LibraryScanProgress struct {
IsComplete bool `json:"is_complete"`
}
// IncrementalScanResult contains results of an incremental library scan
type IncrementalScanResult struct {
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
@ -216,7 +214,6 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
Format: strings.TrimPrefix(ext, "."),
}
// Get file modification time
if info, err := os.Stat(filePath); err == nil {
result.FileModTime = info.ModTime().UnixMilli()
}
@ -466,7 +463,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
}
// Parse existing files map
existingFiles := make(map[string]int64)
if existingFilesJSON != "" && existingFilesJSON != "{}" {
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
@ -476,12 +472,10 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
// Reset progress
libraryScanProgressMu.Lock()
libraryScanProgress = LibraryScanProgress{}
libraryScanProgressMu.Unlock()
// Setup cancellation
libraryScanCancelMu.Lock()
if libraryScanCancel != nil {
close(libraryScanCancel)
@ -490,7 +484,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
cancelCh := libraryScanCancel
libraryScanCancelMu.Unlock()
// Collect all audio files with their mod times
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
if err != nil {
return "{}", err
@ -512,18 +505,14 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
// New file
filesToScan = append(filesToScan, f)
} else if f.modTime != existingModTime {
// Modified file
filesToScan = append(filesToScan, f)
} else {
// Unchanged file - skip
skippedCount++
}
}
// Find deleted files
var deletedPaths []string
for existingPath := range existingFiles {
if !currentPathSet[existingPath] {
@ -551,7 +540,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
return string(jsonBytes), nil
}
// Scan the files that need scanning
results := make([]LibraryScanResult, 0, len(filesToScan))
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0

View file

@ -41,7 +41,6 @@ var DefaultLyricsProviders = []string{
LyricsProviderQQMusic,
}
// Global lyrics provider configuration
var (
lyricsProvidersMu sync.RWMutex
lyricsProviders []string // ordered list of enabled providers
@ -598,7 +597,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return lyricsHasUsableText(l)
}
// Try extension lyrics providers first
if len(extensionProviders) > 0 {
for _, provider := range extensionProviders {
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
@ -621,7 +619,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return &cachedCopy, nil
}
// Get configured provider order
providerOrder := GetLyricsProviderOrder()
simplifiedTrack := simplifyTrackName(trackName)

View file

@ -97,7 +97,6 @@ func (m *appleTokenManager) clearToken() {
m.token = ""
}
// Apple Music API response models
type appleMusicSearchResponse struct {
Results struct {
Songs *struct {
@ -239,15 +238,12 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
return bodyStr, nil
}
// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format.
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
// Try to parse as PaxResponse first
var paxResp paxResponse
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
}
// Try to parse as a direct list of PaxLyrics
var directLyrics []paxLyrics
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil

View file

@ -16,7 +16,6 @@ type MusixmatchClient struct {
baseURL string
}
// Musixmatch proxy response models
type musixmatchSearchResponse struct {
ID int64 `json:"id"`
SongName string `json:"songName"`
@ -116,7 +115,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
}
// Prefer synced lyrics for selected language
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
if len(lines) > 0 {
@ -129,7 +127,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
}
}
// Fall back to unsynced lyrics for selected language
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
@ -162,7 +159,6 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
}
// Prefer synced lyrics
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
if len(lines) > 0 {
@ -175,7 +171,6 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec
}
}
// Fall back to unsynced lyrics
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)

View file

@ -15,7 +15,6 @@ type NeteaseClient struct {
httpClient *http.Client
}
// Netease API response models
type neteaseSearchResponse struct {
Result struct {
Songs []struct {
@ -172,7 +171,6 @@ func (c *NeteaseClient) FetchLyrics(
return nil, err
}
// Parse the LRC text into LyricsResponse
lines := parseSyncedLyrics(lrcText)
if len(lines) == 0 {
// May be plain text lyrics without timestamps

View file

@ -17,7 +17,6 @@ type QQMusicClient struct {
httpClient *http.Client
}
// QQ Music search response models
type qqMusicSearchResponse struct {
Data struct {
Song struct {
@ -184,7 +183,6 @@ func (c *QQMusicClient) FetchLyrics(
}, nil
}
// Fall back to plain text
resultLines := plainTextLyricsLines(lrcText)
if len(resultLines) > 0 {

View file

@ -1,10 +1,8 @@
// mobile_deps.go
// This file ensures gomobile dependencies are not removed by go mod tidy.
// These packages are required by gomobile bind but not directly imported in code.
package gobackend
import (
// Required for gomobile bind to work
_ "golang.org/x/mobile/bind"
)

View file

@ -10,7 +10,6 @@ import (
type TrackIDCacheEntry struct {
TidalTrackID int64
QobuzTrackID int64
AmazonURL string
ExpiresAt time.Time
}
@ -107,25 +106,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
}
}
func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.cache[isrc]
if !exists {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.AmazonURL = amazonURL
now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
c.pruneExpiredLocked(now)
c.lastCleanup = now
}
}
func (c *TrackIDCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
@ -235,8 +215,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
case "qobuz":
preWarmQobuzCache(r.ISRC, r.SpotifyID)
case "amazon":
preWarmAmazonCache(r.ISRC, r.SpotifyID)
}
}(req)
}
@ -256,12 +234,10 @@ func preWarmTidalCache(isrc, _, _ string) {
// 1. From SongLink (fast, no Qobuz API call needed)
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
func preWarmQobuzCache(isrc, spotifyID string) {
// First, try to get QobuzID from SongLink - this is faster and more reliable
if spotifyID != "" {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.QobuzID != "" {
// Parse QobuzID to int64
var trackID int64
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
@ -271,7 +247,6 @@ func preWarmQobuzCache(isrc, spotifyID string) {
}
}
// Fallback: Direct ISRC search on Qobuz API
downloader := NewQobuzDownloader()
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
@ -280,14 +255,6 @@ func preWarmQobuzCache(isrc, spotifyID string) {
}
}
func preWarmAmazonCache(isrc, spotifyID string) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.AmazonURL != "" {
GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
}
}
func PreWarmCache(tracksJSON string) error {
var tracks []struct {
ISRC string `json:"isrc"`

View file

@ -923,18 +923,14 @@ type qobuzAPIResult struct {
duration time.Duration
}
// Qobuz API timeout configuration
// Mobile networks are more unstable, so we use longer timeouts
const (
qobuzAPITimeoutMobile = 25 * time.Second
qobuzMaxRetries = 2 // Number of retries per API
qobuzMaxRetries = 2
qobuzRetryDelay = 500 * time.Millisecond
)
// getQobuzAPITimeout returns appropriate timeout based on platform
// For mobile (gomobile builds), we use longer timeouts
func getQobuzAPITimeout() time.Duration {
// Since this runs in gomobile context, we always use mobile timeout
// The Go backend is only used on mobile (Android/iOS)
return qobuzAPITimeoutMobile
}
@ -944,7 +940,6 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
}
// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
var lastErr error
retryDelay := qobuzRetryDelay
@ -967,7 +962,7 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
if attempt > 0 {
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay)
time.Sleep(retryDelay)
retryDelay *= 2 // Exponential backoff
retryDelay *= 2
}
client := NewHTTPClientWithTimeout(timeout)
@ -1014,11 +1009,10 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
strings.Contains(errStr, "reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "eof") {
continue // Retry
continue
}
break // Non-retryable error
break
}
// Server errors are retryable
if resp.StatusCode >= 500 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
@ -1031,7 +1025,7 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("rate limited")
retryDelay = 2 * time.Second // Wait longer for rate limit
retryDelay = 2 * time.Second
continue
}
@ -1308,7 +1302,6 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
track = nil
} else if track != nil {
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
// Cache for future use
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
}

View file

@ -48,7 +48,6 @@ func (r *RateLimiter) WaitForSlot() {
r.timestamps = append(r.timestamps, time.Now())
}
// cleanOldTimestamps removes timestamps that are outside the current window
func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
cutoff := now.Add(-r.window)
validStart := 0

View file

@ -170,11 +170,9 @@ func JapaneseToRomaji(text string) string {
}
func BuildSearchQuery(trackName, artistName string) string {
// Convert Japanese to romaji
trackRomaji := JapaneseToRomaji(trackName)
artistRomaji := JapaneseToRomaji(artistName)
// Clean up the query - remove special characters that might interfere with search
trackClean := cleanSearchQuery(trackRomaji)
artistClean := cleanSearchQuery(artistRomaji)
@ -196,16 +194,13 @@ func cleanSearchQuery(s string) string {
func CleanToASCII(s string) string {
var result strings.Builder
for _, r := range s {
// Keep only ASCII letters, numbers, spaces, and basic punctuation
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
result.WriteRune(r)
} else if r == ',' || r == '.' {
// Convert punctuation to space
result.WriteRune(' ')
}
}
// Clean up multiple spaces
cleaned := strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(cleaned)
}

View file

@ -291,7 +291,7 @@ func extractDeezerIDFromURL(deezerURL string) string {
return ""
}
// extractQobuzIDFromURL extracts Qobuz track ID from URL
// extractQobuzIDFromURL extracts Qobuz track ID from URL.
// URL formats:
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
// - https://open.qobuz.com/track/12345678
@ -302,29 +302,24 @@ func extractQobuzIDFromURL(qobuzURL string) string {
return ""
}
// Try to find /track/ID pattern first
if strings.Contains(qobuzURL, "/track/") {
parts := strings.Split(qobuzURL, "/track/")
if len(parts) > 1 {
idPart := parts[1]
// Remove query parameters
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
}
// Remove trailing slash or path
if idx := strings.Index(idPart, "/"); idx > 0 {
idPart = idPart[:idx]
}
idPart = strings.TrimSpace(idPart)
// Validate it's a number
if idPart != "" && isNumeric(idPart) {
return idPart
}
}
}
// Try to extract from album URL with track highlight
// Format: /album/albumname/trackid or ?trackId=12345678
// Try to extract from album URL with track highlight (e.g. ?trackId=12345678)
if strings.Contains(qobuzURL, "trackId=") {
parts := strings.Split(qobuzURL, "trackId=")
if len(parts) > 1 {
@ -343,7 +338,6 @@ func extractQobuzIDFromURL(qobuzURL string) string {
parts := strings.Split(qobuzURL, "/")
for i := len(parts) - 1; i >= 0; i-- {
part := parts[i]
// Remove query parameters
if idx := strings.Index(part, "?"); idx > 0 {
part = part[:idx]
}
@ -386,7 +380,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
return ""
}
// Handle youtu.be short URLs
if strings.Contains(youtubeURL, "youtu.be/") {
parts := strings.Split(youtubeURL, "youtu.be/")
if len(parts) >= 2 {
@ -401,7 +394,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
}
}
// Handle youtube.com URLs with ?v= parameter
parsed, err := url.Parse(youtubeURL)
if err != nil {
return ""
@ -411,7 +403,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
return v
}
// Handle /embed/ format
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
@ -540,7 +531,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return availability, nil
}
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()

View file

@ -103,7 +103,7 @@ type MPD struct {
func NewTidalDownloader() *TidalDownloader {
tidalDownloaderOnce.Do(func() {
globalTidalDownloader = &TidalDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
client: NewHTTPClientWithTimeout(DefaultTimeout),
}
apis := globalTidalDownloader.GetAvailableAPIs()
@ -116,7 +116,7 @@ func NewTidalDownloader() *TidalDownloader {
func (t *TidalDownloader) GetAvailableAPIs() []string {
return []string{
"https://tidal-api.binimum.org", // priority
"https://tidal-api.binimum.org",
"https://tidal.kinoplus.online",
"https://triton.squid.wtf",
"https://vogel.qqdl.site",
@ -195,7 +195,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
}
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
}
@ -204,7 +203,6 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
}
// TidalDownloadInfo contains download URL and quality info
type TidalDownloadInfo struct {
URL string
BitDepth int
@ -218,15 +216,13 @@ type tidalAPIResult struct {
duration time.Duration
}
// Tidal API timeout configuration
// Mobile networks are more unstable, so we use longer timeouts
const (
tidalAPITimeoutMobile = 25 * time.Second
tidalMaxRetries = 2 // Number of retries per API
tidalMaxRetries = 2
tidalRetryDelay = 500 * time.Millisecond
)
// fetchTidalURLWithRetry fetches download URL from a single Tidal API with retry logic
func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) {
var lastErr error
retryDelay := tidalRetryDelay
@ -235,7 +231,7 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
if attempt > 0 {
GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay)
time.Sleep(retryDelay)
retryDelay *= 2 // Exponential backoff
retryDelay *= 2
}
client := NewHTTPClientWithTimeout(timeout)
@ -250,17 +246,15 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
resp, err := client.Do(req)
if err != nil {
lastErr = err
// Check for retryable errors (timeout, connection reset)
errStr := strings.ToLower(err.Error())
if strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "eof") {
continue // Retry
continue
}
break // Non-retryable error
break
}
// Server errors are retryable
if resp.StatusCode >= 500 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
@ -273,7 +267,7 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("rate limited")
retryDelay = 2 * time.Second // Wait longer for rate limit
retryDelay = 2 * time.Second
continue
}

View file

@ -1,4 +1,3 @@
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
package gobackend
import (
@ -161,7 +160,6 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize
}
}
// SearchYouTube returns a YouTube Music search URL for the given track
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
searchQuery := url.QueryEscape(query)
@ -213,7 +211,6 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
return resp, nil
}
// requestCobaltDirect sends a download request to the primary Cobalt API.
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
reqBody := CobaltRequest{
URL: videoURL,
@ -470,7 +467,6 @@ func BuildYouTubeWatchURL(videoID string) string {
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
func isYouTubeVideoID(s string) bool {
if len(s) != 11 {
return false
@ -707,7 +703,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
GoLog("[YouTube] Downloading to: %s\n", outputPath)
// Parallel fetch cover art + lyrics
var parallelResult *ParallelDownloadResult
if req.EmbedLyrics || req.CoverURL != "" {
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")

View file

@ -492,13 +492,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getAmazonURLFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "preWarmTrackCache":
let args = call.arguments as! [String: Any]
let tracksJson = args["tracks"] as! String

View file

@ -17,7 +17,6 @@ final _routerProvider = Provider<GoRouter>((ref) {
settingsProvider.select((s) => s.hasCompletedTutorial),
);
// Determine initial location based on app state
String initialLocation;
if (isFirstLaunch) {
initialLocation = '/setup';

View file

@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.7.1';
static const String buildNumber = '104';
static const String version = '3.7.2';
static const String buildNumber = '105';
static const String fullVersion = '$version+$buildNumber';

View file

@ -763,7 +763,7 @@ abstract class AppLocalizations {
/// App description in header card
///
/// In en, this message translates to:
/// **'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'**
/// **'Download Spotify tracks in lossless quality from Tidal and Qobuz.'**
String get aboutAppDescription;
/// Section header for artist albums
@ -1576,7 +1576,7 @@ abstract class AppLocalizations {
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
String get providerPriorityInfo;
/// Label for built-in providers (Tidal/Qobuz/Amazon)
/// Label for built-in providers (Tidal/Qobuz)
///
/// In en, this message translates to:
/// **'Built-in'**
@ -3271,7 +3271,7 @@ abstract class AppLocalizations {
/// Tutorial welcome tip 2
///
/// In en, this message translates to:
/// **'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'**
/// **'Get FLAC quality audio from Tidal, Qobuz, or Deezer'**
String get tutorialWelcomeTip2;
/// Tutorial welcome tip 3

View file

@ -365,7 +365,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutAppDescription =>
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.';
@override
String get artistAlbums => 'Alben';
@ -1826,7 +1826,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'FLAC-Qualität von Tidal, Qobuz oder Deezer';
@override
String get tutorialWelcomeTip3 =>

View file

@ -356,7 +356,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@ -1809,7 +1809,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>

View file

@ -356,7 +356,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@ -1809,7 +1809,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@ -2705,7 +2705,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get aboutAppDescription =>
'Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.';
'Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.';
@override
String get artistAlbums => 'Álbumes';
@ -4150,7 +4150,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer';
@override
String get tutorialWelcomeTip3 =>

View file

@ -358,7 +358,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@ -1811,7 +1811,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Audio en qualité FLAC depuis Tidal, Qobuz ou Deezer';
@override
String get tutorialWelcomeTip3 =>

View file

@ -356,7 +356,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@ -1809,7 +1809,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Tidal, Qobuz, या Deezer से FLAC गुणवत्ता ऑडियो प्राप्त करें';
@override
String get tutorialWelcomeTip3 =>

View file

@ -359,7 +359,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutAppDescription =>
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
'Unduh lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.';
@override
String get artistAlbums => 'Album';
@ -1816,7 +1816,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Deezer';
@override
String get tutorialWelcomeTip3 =>

View file

@ -352,7 +352,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutAppDescription =>
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
'Tidal、Qobuz から Spotify のトラックをロスレス品質でダウンロードします。';
@override
String get artistAlbums => 'アルバム';
@ -1795,8 +1795,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
String get tutorialWelcomeTip2 => 'Tidal、Qobuz、Deezer から FLAC 品質のオーディオを取得';
@override
String get tutorialWelcomeTip3 =>

View file

@ -355,7 +355,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@ -1807,8 +1807,7 @@ class AppLocalizationsKo extends AppLocalizations {
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
String get tutorialWelcomeTip2 => 'Tidal, Qobuz 또는 Deezer에서 FLAC 품질 오디오 받기';
@override
String get tutorialWelcomeTip3 =>

View file

@ -356,7 +356,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@ -1809,7 +1809,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Krijg FLAC-kwaliteit audio van Tidal, Qobuz of Deezer';
@override
String get tutorialWelcomeTip3 =>

View file

@ -356,7 +356,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@ -1809,7 +1809,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@ -2705,7 +2705,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get aboutAppDescription =>
'Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.';
'Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.';
@override
String get artistAlbums => 'Álbuns';
@ -4147,7 +4147,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer';
@override
String get tutorialWelcomeTip3 =>

View file

@ -363,7 +363,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutAppDescription =>
'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.';
'Скачайте треки Spotify в Lossless качестве из Tidal и Qobuz.';
@override
String get artistAlbums => 'Альбомы';
@ -1855,8 +1855,7 @@ class AppLocalizationsRu extends AppLocalizations {
'Скачивайте музыку из Spotify, Deezer, или вставьте любой поддерживаемый URL';
@override
String get tutorialWelcomeTip2 =>
'Скачайте FLAC с Tidal, Qobuz или Amazon Music';
String get tutorialWelcomeTip2 => 'Скачайте FLAC с Tidal, Qobuz или Deezer';
@override
String get tutorialWelcomeTip3 =>

View file

@ -361,7 +361,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get aboutAppDescription =>
'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.';
'Spotify şarkılarını Tidal ve Qobuz\'den yüksek kalitede indir.';
@override
String get artistAlbums => 'Albümler';
@ -1821,7 +1821,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Tidal, Qobuz veya Deezer\'den FLAC kalitesinde ses alın';
@override
String get tutorialWelcomeTip3 =>

View file

@ -356,7 +356,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@ -1809,7 +1809,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@ -2696,7 +2696,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@ -4124,8 +4124,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
String get tutorialWelcomeTip2 => '从 Tidal、Qobuz 或 Deezer 获取 FLAC 品质音频';
@override
String get tutorialWelcomeTip3 =>
@ -4808,7 +4807,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@ -6236,8 +6235,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
String get tutorialWelcomeTip2 => '從 Tidal、Qobuz 或 Deezer 取得 FLAC 品質音訊';
@override
String get tutorialWelcomeTip3 =>

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Integriert",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Erweiterung",
"@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "FLAC-Qualität von Tidal, Qobuz oder Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1097,7 +1097,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@ -2383,7 +2383,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -402,7 +402,7 @@
"@aboutDabMusicDesc": {
"description": "Credit for DAB Music API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1005,7 +1005,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.",
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Integrado",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extensión",
"@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Audio en qualité FLAC depuis Tidal, Qobuz ou Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Tidal, Qobuz, या Deezer से FLAC गुणवत्ता ऑडियो प्राप्त करें",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1097,7 +1097,7 @@
},
"providerBuiltIn": "Bawaan",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Ekstensi",
"@providerExtension": {
@ -2383,7 +2383,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。",
"aboutAppDescription": "Tidal、Qobuz から Spotify のトラックをロスレス品質でダウンロードします。",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "内蔵",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "拡張",
"@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Tidal、Qobuz、Deezer から FLAC 品質のオーディオを取得",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Tidal, Qobuz 또는 Deezer에서 FLAC 품질 오디오 받기",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Krijg FLAC-kwaliteit audio van Tidal, Qobuz of Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -402,7 +402,7 @@
"@aboutDabMusicDesc": {
"description": "Credit for DAB Music API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1005,7 +1005,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.",
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Embutido",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extensão",
"@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.",
"aboutAppDescription": "Скачайте треки Spotify в Lossless качестве из Tidal и Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Встроенные",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Расширение",
"@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Скачайте FLAC с Tidal, Qobuz или Amazon Music",
"tutorialWelcomeTip2": "Скачайте FLAC с Tidal, Qobuz или Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Spotify şarkılarını Tidal, Qobuz ve Amazon Music'den yüksek kalitede indir.",
"aboutAppDescription": "Spotify şarkılarını Tidal ve Qobuz'den yüksek kalitede indir.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Dahili",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Eklenti",
"@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Tidal, Qobuz veya Deezer'den FLAC kalitesinde ses alın",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -402,7 +402,7 @@
"@aboutDabMusicDesc": {
"description": "Credit for DAB Music API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1005,7 +1005,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "从 Tidal、Qobuz 或 Deezer 获取 FLAC 品质音频",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "從 Tidal、Qobuz 或 Deezer 取得 FLAC 品質音訊",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},

View file

@ -55,17 +55,14 @@ class AppSettings {
final String
songLinkRegion; // SongLink userCountry region code used for platform lookup
// Local Library Settings
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
// Tutorial/Onboarding
final bool
hasCompletedTutorial; // Track if user has completed the app tutorial
// Lyrics Provider Settings
final List<String>
lyricsProviders; // Ordered list of enabled lyrics provider IDs
final bool
@ -77,7 +74,6 @@ class AppSettings {
final String
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
// Version upgrade tracking
final String
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
@ -124,13 +120,10 @@ class AppSettings {
this.downloadNetworkMode = 'any',
this.networkCompatibilityMode = false,
this.songLinkRegion = 'US',
// Local Library defaults
this.localLibraryEnabled = false,
this.localLibraryPath = '',
this.localLibraryShowDuplicates = true,
// Tutorial default
this.hasCompletedTutorial = false,
// Lyrics providers default order
this.lyricsProviders = const [
'lrclib',
'spotify_api',
@ -143,7 +136,6 @@ class AppSettings {
this.lyricsIncludeRomanizationNetease = false,
this.lyricsMultiPersonWordByWord = false,
this.musixmatchLanguage = '',
// Version upgrade tracking
this.lastSeenVersion = '',
});
@ -191,19 +183,15 @@ class AppSettings {
String? downloadNetworkMode,
bool? networkCompatibilityMode,
String? songLinkRegion,
// Local Library
bool? localLibraryEnabled,
String? localLibraryPath,
bool? localLibraryShowDuplicates,
// Tutorial
bool? hasCompletedTutorial,
// Lyrics providers
List<String>? lyricsProviders,
bool? lyricsIncludeTranslationNetease,
bool? lyricsIncludeRomanizationNetease,
bool? lyricsMultiPersonWordByWord,
String? musixmatchLanguage,
// Version upgrade tracking
String? lastSeenVersion,
}) {
return AppSettings(
@ -259,14 +247,11 @@ class AppSettings {
networkCompatibilityMode:
networkCompatibilityMode ?? this.networkCompatibilityMode,
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
// Local Library
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
// Tutorial
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
// Lyrics providers
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
lyricsIncludeTranslationNetease:
lyricsIncludeTranslationNetease ??
@ -277,7 +262,6 @@ class AppSettings {
lyricsMultiPersonWordByWord:
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
// Version upgrade tracking
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
);
}

View file

@ -592,10 +592,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return 0;
}
// Delete from database
final deletedCount = await _db.deleteByIds(orphanedIds);
// Update in-memory state
final orphanedSet = orphanedIds.toSet();
state = state.copyWith(
items: state.items
@ -1596,18 +1594,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
String _determineOutputExt(String quality, String service) {
// YouTube provider - lossy only (Opus or MP3)
if (service.toLowerCase() == 'youtube') {
if (quality.toLowerCase().contains('mp3')) {
return '.mp3';
}
return '.opus';
}
// Amazon stream is delivered as MP4/M4A container (may contain FLAC audio),
// so SAF should keep .m4a before decrypt/convert pipeline.
if (service.toLowerCase() == 'amazon') {
return '.m4a';
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a';
}
@ -2903,7 +2895,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
failedCount: _failedInSession,
);
// Auto-export failed downloads if enabled
final settings = ref.read(settingsProvider);
if (settings.autoExportFailedDownloads && _failedInSession > 0) {
final exportPath = await exportFailedDownloads();
@ -3206,7 +3197,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
!trackToDownload.id.startsWith('deezer:') &&
!trackToDownload.id.startsWith('extension:')) {
try {
// Extract clean Spotify ID (remove spotify: prefix if present)
String spotifyId = trackToDownload.id;
if (spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.split(':').last;
@ -3508,9 +3498,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (!wasExisting &&
decryptionKey.isNotEmpty &&
filePath != null &&
actualService == 'amazon') {
_log.i('Amazon encrypted stream detected, decrypting via FFmpeg...');
filePath != null) {
_log.i('Encrypted stream detected, decrypting via FFmpeg...');
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
if (effectiveSafMode && isContentUri(filePath)) {
@ -3539,7 +3528,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Failed to decrypt Amazon stream',
error: 'Failed to decrypt encrypted stream',
errorType: DownloadErrorType.unknown,
);
return;
@ -3564,7 +3553,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
if (newUri == null) {
_log.e('Failed to write decrypted Amazon stream back to SAF');
_log.e('Failed to write decrypted stream back to SAF');
updateItemStatus(
item.id,
DownloadStatus.failed,
@ -3579,7 +3568,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
filePath = newUri;
finalSafFileName = newFileName;
_log.i('Amazon SAF decryption completed');
_log.i('SAF decryption completed');
} finally {
try {
await File(tempPath).delete();
@ -3601,7 +3590,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Failed to decrypt Amazon stream',
error: 'Failed to decrypt encrypted stream',
errorType: DownloadErrorType.unknown,
);
try {
@ -3610,7 +3599,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
filePath = decryptedPath;
_log.i('Amazon local decryption completed');
_log.i('Local decryption completed');
}
}
@ -3832,7 +3821,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} else {
// Local file path flow (original)
if (quality == 'HIGH') {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
@ -4049,10 +4037,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
!effectiveSafMode &&
isFlacFile &&
!wasExisting &&
actualService == 'amazon' &&
decryptionKey.isNotEmpty) {
_log.d(
'Local FLAC after Amazon decrypt detected, embedding metadata and cover...',
'Local FLAC after decrypt detected, embedding metadata and cover...',
);
try {
updateItemStatus(
@ -4112,7 +4099,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final isContentUriPath = isContentUri(filePath);
if (isContentUriPath && effectiveSafMode) {
// SAF mode: copy to temp, embed, write back
final tempPath = await _copySafToTemp(filePath);
if (tempPath != null) {
try {
@ -4133,7 +4119,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
copyright: backendCopyright,
);
}
// Write back to SAF
final ext = isMp3File ? '.mp3' : '.opus';
final newFileName = '${safBaseName ?? 'track'}$ext';
final newUri = await _writeTempToSaf(
@ -4162,7 +4147,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} else {
// Non-SAF mode: embed directly
try {
if (isMp3File) {
await _embedMetadataToMp3(

View file

@ -757,7 +757,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> loadProviderPriority() async {
try {
// Load from SharedPreferences first (persisted)
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(_providerPriorityKey);
@ -768,10 +767,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
priority = _sanitizeDownloadProviderPriority(priority);
_log.d('Loaded provider priority from prefs: $priority');
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
// Sync to Go backend
await PlatformBridge.setProviderPriority(priority);
} else {
// Fallback to Go backend default
priority = await PlatformBridge.getProviderPriority();
priority = _sanitizeDownloadProviderPriority(priority);
await PlatformBridge.setProviderPriority(priority);
@ -787,11 +784,9 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> setProviderPriority(List<String> priority) async {
try {
final sanitized = _sanitizeDownloadProviderPriority(priority);
// Save to SharedPreferences for persistence
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
// Sync to Go backend
await PlatformBridge.setProviderPriority(sanitized);
state = state.copyWith(providerPriority: sanitized);
_log.d('Saved provider priority: $sanitized');
@ -811,7 +806,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
for (final provider in const ['tidal', 'qobuz', 'amazon', 'deezer']) {
for (final provider in const ['tidal', 'qobuz', 'deezer']) {
if (!result.contains(provider)) {
result.add(provider);
}
@ -822,7 +817,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> loadMetadataProviderPriority() async {
try {
// Load from SharedPreferences first (persisted)
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(_metadataProviderPriorityKey);
@ -831,10 +825,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
final saved = jsonDecode(savedJson) as List<dynamic>;
priority = saved.map((e) => e as String).toList();
_log.d('Loaded metadata provider priority from prefs: $priority');
// Sync to Go backend
await PlatformBridge.setMetadataProviderPriority(priority);
} else {
// Fallback to Go backend default
priority = await PlatformBridge.getMetadataProviderPriority();
_log.d('Using default metadata provider priority: $priority');
}
@ -847,11 +839,9 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> setMetadataProviderPriority(List<String> priority) async {
try {
// Save to SharedPreferences for persistence
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
// Sync to Go backend
await PlatformBridge.setMetadataProviderPriority(priority);
state = state.copyWith(metadataProviderPriority: priority);
_log.d('Saved metadata provider priority: $priority');
@ -880,7 +870,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz', 'amazon', 'deezer'];
final providers = ['tidal', 'qobuz', 'deezer'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasDownloadProvider) {
providers.add(ext.id);

View file

@ -204,7 +204,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// Step 1: Check for extension URL handlers first (handles YT Music, etc.)
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
@ -215,7 +214,6 @@ class TrackNotifier extends Notifier<TrackState> {
result = await PlatformBridge.handleURLWithExtension(url);
if (!_isRequestValid(requestId)) return;
// Check if we got valid data
if (result != null &&
result['type'] == 'track' &&
result['track'] != null) {
@ -321,7 +319,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Step 2: Try Deezer URL parsing
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
_log.i('Detected Deezer URL, parsing...');
final parsed = await PlatformBridge.parseDeezerUrl(url);
@ -387,7 +384,6 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
// Step 3: Try Tidal URL parsing
if (url.contains('tidal.com')) {
_log.i('Detected Tidal URL, parsing...');
final parsed = await PlatformBridge.parseTidalUrl(url);
@ -461,7 +457,6 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
// Step 4: Fall back to Spotify parsing
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return;

View file

@ -305,7 +305,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background (no blur, full resolution)
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl:
@ -326,7 +325,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@ -345,7 +343,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
),
),
// Album info overlay at bottom
Positioned(
left: 20,
right: 20,

View file

@ -21,7 +21,6 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
/// Simple in-memory cache for artist data
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
@ -69,7 +68,6 @@ class _CacheEntry {
});
}
/// Artist screen with Spotify-like design
class ArtistScreen extends ConsumerStatefulWidget {
final String artistId;
final String artistName;
@ -717,7 +715,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
),
const Divider(height: 1),
// Options
if (albums.isNotEmpty)
_DiscographyOptionTile(
icon: Icons.library_music,
@ -830,7 +827,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
int failedCount = 0;
for (final album in albums) {
if (!_isFetchingDiscography) break; // Cancelled
if (!_isFetchingDiscography) break;
try {
final tracks = await _fetchAlbumTracks(album);
@ -1066,7 +1063,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
alignment: Alignment.topCenter, // Show top of image (faces)
alignment: Alignment.topCenter,
memCacheWidth: 800,
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) =>
@ -1155,7 +1152,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
],
),
),
// Download Discography button (icon only, right-aligned)
if (hasDiscography && !_isSelectionMode) ...[
const SizedBox(width: 12),
Container(
@ -1201,7 +1197,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
}
/// Build Popular tracks section like Spotify
Widget _buildPopularSection(ColorScheme colorScheme) {
if (_topTracks == null || _topTracks!.isEmpty) {
return const SizedBox.shrink();
@ -1416,7 +1411,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
}
/// Handle tap on popular track item
void _handlePopularTrackTap(Track track, {required bool isQueued}) async {
if (isQueued) return;
@ -1636,7 +1630,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
),
),
// Selection overlay
if (_isSelectionMode)
Positioned.fill(
child: AnimatedContainer(
@ -1652,7 +1645,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
),
),
// Checkbox
if (_isSelectionMode)
Positioned(
top: 8,

View file

@ -18,7 +18,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
/// Screen to display downloaded tracks from a specific album
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
@ -361,7 +360,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
final tracks = _getAlbumTracks(allHistoryItems);
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
@ -480,7 +478,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background
if (embeddedCoverPath != null)
Image.file(
File(embeddedCoverPath),
@ -508,7 +505,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@ -527,7 +523,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
),
),
// Album info overlay at bottom
Positioned(
left: 20,
right: 20,
@ -711,10 +706,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final discTracks = discMap[discNumber];
if (discTracks == null || discTracks.isEmpty) continue;
// Add disc separator
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
// Add tracks for this disc
for (final track in discTracks) {
children.add(
KeyedSubtree(
@ -897,7 +890,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return;
}
// Share SAF content URIs via native intent
if (safUris.isNotEmpty) {
try {
if (safUris.length == 1) {
@ -908,13 +900,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} catch (_) {}
}
// Share regular files via SharePlus
if (filesToShare.isNotEmpty) {
await SharePlus.instance.share(ShareParams(files: filesToShare));
}
}
/// Show batch convert bottom sheet
void _showBatchConvertSheet(
BuildContext context,
List<DownloadHistoryItem> allTracks,
@ -1388,7 +1378,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
const SizedBox(height: 12),
// Action buttons row: Share, Convert
Row(
children: [
Expanded(

View file

@ -520,7 +520,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
final searchProvider = settings.searchProvider;
// Use filterOverride if provided, otherwise read from state
final selectedFilter =
filterOverride ?? ref.read(trackProvider).selectedSearchFilter;
@ -535,7 +534,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
if (isExtensionEnabled) {
// Build options with filter if selected
Map<String, dynamic>? options;
if (selectedFilter != null) {
options = {'filter': selectedFilter};
@ -1116,7 +1114,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
// Search filter bar (only shown when has search results)
if (hasActualResults && !showRecentAccess)
Consumer(
builder: (context, ref, _) {
@ -1286,8 +1283,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
],
),
), // Close RefreshIndicator
), // Close GestureDetector
),
),
);
}
@ -1434,7 +1431,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
return _buildExploreSection(sections[sectionIndex], colorScheme);
}
// Bottom padding
return const SizedBox(height: 16);
}, childCount: totalCount),
),
@ -2705,7 +2701,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
scrollDirection: Axis.horizontal,
child: Row(
children: [
// "All" chip (no filter)
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
@ -2728,7 +2723,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
),
// Filter chips from extension
...filters.map((filter) {
final isSelected = selectedFilter == filter.id;
return Padding(
@ -2830,7 +2824,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
prefixIcon: _SearchProviderDropdown(
onProviderChanged: () {
_lastSearchQuery = null;
// Reset filter when provider changes
ref.read(trackProvider.notifier).setSearchFilter(null);
setState(() {});
final text = _urlController.text.trim();

View file

@ -158,7 +158,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header: drag handle + thumbnail + playlist info
Column(
children: [
const SizedBox(height: 8),
@ -210,7 +209,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
// Rename
_PlaylistOptionTile(
icon: Icons.edit_outlined,
title: context.l10n.collectionRenamePlaylist,
@ -225,7 +223,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
},
),
// Change cover
_PlaylistOptionTile(
icon: Icons.image_outlined,
title: context.l10n.collectionPlaylistChangeCover,
@ -235,7 +232,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
},
),
// Delete
_PlaylistOptionTile(
icon: Icons.delete_outline,
iconColor: colorScheme.error,

View file

@ -37,7 +37,6 @@ class _LibraryTracksFolderScreenState
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
// Multi-select state
bool _isSelectionMode = false;
final Set<String> _selectedKeys = {};
@ -145,8 +144,6 @@ class _LibraryTracksFolderScreenState
return url;
}
// Selection helpers
void _enterSelectionMode(String key) {
HapticFeedback.mediumImpact();
setState(() {
@ -181,8 +178,6 @@ class _LibraryTracksFolderScreenState
});
}
// Batch actions
Future<void> _removeSelected(List<CollectionTrackEntry> entries) async {
final keysToRemove = _selectedKeys.toSet();
if (keysToRemove.isEmpty) return;
@ -426,7 +421,6 @@ class _LibraryTracksFolderScreenState
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Container(
width: 32,
height: 4,
@ -437,7 +431,6 @@ class _LibraryTracksFolderScreenState
),
),
// Header: [X close] [count] [Select All / Deselect]
Row(
children: [
IconButton.filledTonal(
@ -493,7 +486,6 @@ class _LibraryTracksFolderScreenState
const SizedBox(height: 12),
// Action buttons row
Row(
children: [
if (isWishlist)
@ -525,7 +517,6 @@ class _LibraryTracksFolderScreenState
const SizedBox(height: 8),
// Remove button (full width, red)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
@ -714,7 +705,6 @@ class _LibraryTracksFolderScreenState
)
else
coverFallback,
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@ -733,7 +723,6 @@ class _LibraryTracksFolderScreenState
),
),
),
// Title and track count overlay
Positioned(
left: 20,
right: 20,
@ -829,8 +818,6 @@ class _LibraryTracksFolderScreenState
);
}
// Header actions
Widget _buildHeaderActionPlaceholder() => const SizedBox(width: 48, height: 48);
Widget _buildDownloadAllCenterButton(List<CollectionTrackEntry> entries) {
@ -1263,7 +1250,6 @@ class _CollectionTrackTile extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header: drag handle + cover + track info
Column(
children: [
const SizedBox(height: 8),

View file

@ -13,7 +13,6 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
/// Screen to display tracks from a local library album
class LocalAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
@ -83,7 +82,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
@ -197,7 +195,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
);
// Go back if all tracks were deleted
if (deletedCount == currentTracks.length) {
Navigator.pop(context);
}
@ -233,7 +230,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final bottomPadding = MediaQuery.of(context).padding.bottom;
final tracks = _sortedTracksCache;
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
@ -326,7 +322,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background
if (widget.coverPath != null)
Image.file(
File(widget.coverPath!),
@ -343,7 +338,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@ -362,7 +356,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
),
),
// Album info overlay at bottom
Positioned(
left: 20,
right: 20,
@ -888,7 +881,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return false;
}
/// Batch re-enrich selected local tracks
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
@ -988,7 +980,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
).showSnackBar(SnackBar(content: Text(summary)));
}
/// Show batch convert bottom sheet
void _showBatchConvertSheet(
BuildContext context,
List<LocalLibraryItem> allTracks,
@ -1261,7 +1252,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
String? safTempPath;
if (isSaf) {
// Copy SAF file to temp for conversion
safTempPath = await PlatformBridge.copyContentUriToTemp(
item.filePath,
);
@ -1296,7 +1286,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (isSaf) {
// For SAF: derive the parent tree URI and relative dir from the content URI,
// then create new SAF file and delete old one
//
// Parse the SAF URI to get the tree document path:
// content://...tree/...document/.../oldName.flac
// We need tree URI and relative dir to create the new file
@ -1375,14 +1364,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
continue;
}
// Delete old SAF file
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
await localDb.deleteByPath(item.filePath);
}
// Clean up temp files
try {
await File(newPath).delete();
} catch (_) {}
@ -1400,7 +1387,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} catch (_) {}
}
// Reload local library to pick up converted files
ref.read(localLibraryProvider.notifier).reloadFromStorage();
_exitSelectionMode();
@ -1513,7 +1499,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
const SizedBox(height: 12),
// Action buttons row: Re-enrich, Convert
Row(
children: [
Expanded(

View file

@ -206,7 +206,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl:
@ -227,7 +226,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@ -246,7 +244,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
),
),
),
// Playlist info overlay at bottom
Positioned(
left: 20,
right: 20,
@ -436,8 +433,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
}
// Shuffle / Love / Download buttons
Widget _buildCircleButton({
required IconData icon,
required String tooltip,

View file

@ -211,7 +211,7 @@ class _GroupedAlbum {
class _GroupedLocalAlbum {
final String albumName;
final String artistName;
final String? coverPath; // Local cover file path
final String? coverPath;
final List<LocalLibraryItem> tracks;
final DateTime latestScanned;
final String searchKey;
@ -229,12 +229,11 @@ class _GroupedLocalAlbum {
class _HistoryStats {
final Map<String, int> albumCounts;
final Map<String, int> localAlbumCounts; // For identifying local singles
final Map<String, int> localAlbumCounts;
final List<_GroupedAlbum> groupedAlbums;
final List<_GroupedLocalAlbum> groupedLocalAlbums; // Local library albums
final List<_GroupedLocalAlbum> groupedLocalAlbums;
final int albumCount;
final int singleTracks;
// Local library stats
final int localAlbumCount;
final int localSingleTracks;
@ -933,8 +932,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
overlay.insert(_playlistSelectionOverlayEntry!);
}
// --- Playlist selection mode ---
void _enterPlaylistSelectionMode(String playlistId) {
HapticFeedback.mediumImpact();
setState(() {
@ -1202,11 +1199,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
await deleteFile(cleanPath);
} catch (_) {}
// Remove from appropriate database
if (item.source == LibraryItemSource.downloaded) {
historyNotifier.removeFromHistory(item.historyItem!.id);
} else {
// Remove from local library database
await localLibraryDb.deleteByPath(item.filePath);
}
deletedCount++;
@ -2024,7 +2019,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Map<String, int> albumCounts, [
String searchQuery = '',
]) {
// First apply search filter
var filteredItems = items;
if (searchQuery.isNotEmpty) {
final query = searchQuery;
@ -2034,7 +2028,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}).toList();
}
// Then apply filter mode
if (filterMode == 'all') return filteredItems;
switch (filterMode) {
@ -2639,7 +2632,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// Search bar - always at top
if (allHistoryItems.isNotEmpty ||
hasQueueItems ||
localLibraryItems.isNotEmpty)

View file

@ -49,7 +49,7 @@ class AboutPage extends StatelessWidget {
title: Text(
context.l10n.aboutTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
@ -462,7 +462,6 @@ class _ContributorItem extends StatelessWidget {
}
}
/// Translator data model
class _Translator {
final String name;
final String crowdinUsername;
@ -477,7 +476,6 @@ class _Translator {
});
}
/// Translators section with compact chip-style layout
class _TranslatorsSection extends StatelessWidget {
const _TranslatorsSection();
@ -558,7 +556,6 @@ class _TranslatorsSection extends StatelessWidget {
}
}
/// Individual translator chip
class _TranslatorChip extends StatelessWidget {
final _Translator translator;

View file

@ -148,7 +148,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
}
}
/// A simplified preview of how the app looks with current settings
class _ThemePreviewCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -423,7 +422,6 @@ class _ColorPaletteItem extends StatelessWidget {
}
}
/// Optimized app bar title with animation
class _AppBarTitle extends StatelessWidget {
final String title;
final double topPadding;
@ -440,14 +438,14 @@ class _AppBarTitle extends StatelessWidget {
final expandRatio =
((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
title,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),

View file

@ -56,17 +56,14 @@ class DonatePage extends StatelessWidget {
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Donate links card
_DonateLinksCard(colorScheme: colorScheme),
const SizedBox(height: 24),
// Recent donors section
_RecentDonorsCard(colorScheme: colorScheme),
const SizedBox(height: 16),
// Combined notice card
Card(
elevation: 0,
color: colorScheme.secondaryContainer.withValues(

View file

@ -23,7 +23,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
}
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon', 'deezer'];
static const _builtInServices = ['tidal', 'qobuz', 'deezer'];
static const _songLinkRegions = [
'AD',
'AE',
@ -326,7 +326,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
@ -336,7 +336,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
title: Text(
context.l10n.downloadTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
@ -450,7 +450,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
const SizedBox(width: 8),
Expanded(
child: Text(
'Select Tidal, Qobuz, or Amazon above to configure quality',
'Select Tidal or Qobuz above to configure quality',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
@ -720,7 +720,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
),
// Download Network Mode
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
),
@ -778,7 +777,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
),
// All Files Access section (Android 13+ only)
if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(
@ -2039,7 +2037,7 @@ class _ServiceSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'deezer', 'youtube'];
final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube'];
final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider)
@ -2076,15 +2074,6 @@ class _ServiceSelector extends ConsumerWidget {
),
),
const SizedBox(width: 8),
Expanded(
child: _ServiceChip(
icon: Icons.shopping_bag_outlined,
label: 'Amazon',
isSelected: effectiveService == 'amazon',
onTap: () => onChanged('amazon'),
),
),
const SizedBox(width: 8),
Expanded(
child: _ServiceChip(
icon: Icons.smart_display,

View file

@ -658,7 +658,6 @@ class _SettingItemState extends State<_SettingItem> {
);
}
// For button type, show a different layout
if (widget.setting.type == 'button') {
return Column(
mainAxisSize: MainAxisSize.min,

View file

@ -271,7 +271,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
),
),
// Scan Settings Section
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.libraryScanSettings,
@ -442,7 +441,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
),
],
// Info Section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
@ -558,7 +556,6 @@ class _LibraryHeroCard extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Background decorative elements
Positioned(
right: -20,
top: -20,
@ -581,7 +578,6 @@ class _LibraryHeroCard extends StatelessWidget {
),
),
// Content
Padding(
padding: const EdgeInsets.all(24),
child: Column(

View file

@ -122,8 +122,6 @@ class _LyricsProviderPriorityPageState
);
}
// State mutations
void _enableProvider(String id) {
setState(() => _enabledProviders.add(id));
_markChanged();
@ -142,8 +140,6 @@ class _LyricsProviderPriorityPageState
_markChanged();
}
// Save / Discard
Future<void> _saveChanges() async {
ref
.read(settingsProvider.notifier)
@ -180,8 +176,6 @@ class _LyricsProviderPriorityPageState
return result ?? false;
}
// Provider metadata
static _LyricsProviderInfo _getLyricsProviderInfo(String id) {
switch (id) {
case 'spotify_api':
@ -230,10 +224,6 @@ class _LyricsProviderPriorityPageState
}
}
//
// Enabled provider card (reorderable)
//
class _EnabledProviderItem extends StatelessWidget {
final String providerId;
final _LyricsProviderInfo info;
@ -273,7 +263,6 @@ class _EnabledProviderItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Numbered badge
Container(
width: 28,
height: 28,
@ -296,10 +285,8 @@ class _EnabledProviderItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Icon
Icon(info.icon, color: colorScheme.primary),
const SizedBox(width: 12),
// Name + description
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -319,7 +306,6 @@ class _EnabledProviderItem extends StatelessWidget {
],
),
),
// Enable/disable switch
SizedBox(
height: 32,
child: FittedBox(
@ -327,7 +313,6 @@ class _EnabledProviderItem extends StatelessWidget {
),
),
const SizedBox(width: 4),
// Drag handle
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
],
),
@ -338,10 +323,6 @@ class _EnabledProviderItem extends StatelessWidget {
}
}
//
// Disabled provider card
//
class _DisabledProviderItem extends StatelessWidget {
final String providerId;
final _LyricsProviderInfo info;
@ -383,10 +364,8 @@ class _DisabledProviderItem extends StatelessWidget {
// Empty space aligned with numbered badge
const SizedBox(width: 28),
const SizedBox(width: 16),
// Icon (muted)
Icon(info.icon, color: colorScheme.outline),
const SizedBox(width: 12),
// Name + description
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -407,7 +386,6 @@ class _DisabledProviderItem extends StatelessWidget {
],
),
),
// Switch
SizedBox(
height: 32,
child: FittedBox(
@ -424,10 +402,6 @@ class _DisabledProviderItem extends StatelessWidget {
}
}
//
// Provider info model
//
class _LyricsProviderInfo {
final String name;
final String description;

View file

@ -43,7 +43,7 @@ class OptionsSettingsPage extends ConsumerWidget {
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
@ -53,7 +53,7 @@ class OptionsSettingsPage extends ConsumerWidget {
title: Text(
context.l10n.optionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
@ -331,7 +331,6 @@ class OptionsSettingsPage extends ConsumerWidget {
BuildContext context,
WidgetRef ref,
) async {
// Show loading indicator
showDialog(
context: context,
barrierDismissible: false,

View file

@ -333,12 +333,6 @@ class _ProviderItem extends StatelessWidget {
);
case 'qobuz':
return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true);
case 'amazon':
return _ProviderInfo(
name: 'Amazon Music',
icon: Icons.shopping_bag,
isBuiltIn: true,
);
case 'youtube':
return _ProviderInfo(
name: 'YouTube',

View file

@ -22,7 +22,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final PageController _pageController = PageController();
int _currentStep = 0;
// State variables
bool _storagePermissionGranted = false;
bool _notificationPermissionGranted = false;
String? _selectedDirectory;
@ -474,7 +473,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Calculate progress
final progress = (_currentStep + 1) / _totalSteps;
return Scaffold(
@ -482,7 +480,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
body: SafeArea(
child: Column(
children: [
// Top Bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
@ -497,9 +494,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
)
else
const SizedBox(width: 48), // Spacer
const SizedBox(width: 48),
const Spacer(),
// Progress Indicator
SizedBox(
width: 48,
height: 48,
@ -530,7 +526,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
),
// Content
Expanded(
child: PageView(
controller: _pageController,

View file

@ -627,7 +627,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background
if (_hasPath(_embeddedCoverPreviewPath))
Image.file(
File(_embeddedCoverPreviewPath!),
@ -657,7 +656,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@ -676,7 +674,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
),
// Track info overlay at bottom
Positioned(
left: 20,
right: 20,
@ -2606,7 +2603,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 20),
// Target format
Text(
context.l10n.trackConvertTargetFormat,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
@ -2639,7 +2635,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 16),
// Bitrate
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
@ -2664,7 +2659,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 24),
// Convert button
SizedBox(
width: double.infinity,
child: FilledButton(
@ -2750,7 +2744,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
SnackBar(content: Text(context.l10n.trackConvertConverting)),
);
// Step 1: Read metadata from file (fallback to known item metadata).
final metadata = _buildFallbackMetadata();
try {
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
@ -2768,7 +2761,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_log.w('readFileMetadata threw, using fallback metadata: $e');
}
// Step 2: Extract cover art to temp file
String? coverPath;
try {
final tempDir = await getTemporaryDirectory();
@ -2783,7 +2775,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
} catch (_) {}
// Step 3: Handle SAF vs regular file
String workingPath = cleanFilePath;
final isSaf = _isSafFile;
String? safTempPath;
@ -2803,7 +2794,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
workingPath = safTempPath;
}
// Step 4: Convert
final newPath = await FFmpegService.convertAudioFormat(
inputPath: workingPath,
targetFormat: targetFormat.toLowerCase(),
@ -2838,7 +2828,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final newQuality = _buildConvertedQualityLabel(targetFormat, bitrate);
// Step 5: Handle SAF write-back
if (isSaf) {
final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? '';
@ -3064,7 +3053,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// Also remove from local library database
// ref.read(localLibraryProvider.notifier).removeItem(_localLibraryItem!.id);
} else {
// Existing download history deletion logic
try {
await deleteFile(cleanFilePath);
} catch (e) {
@ -3661,7 +3649,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
expand: false,
builder: (context, scrollController) => Column(
children: [
// Handle bar
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
@ -3673,7 +3660,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
),
),
// Title row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
@ -3698,7 +3684,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
),
const SizedBox(height: 12),
// Fields
Expanded(
child: ListView(
controller: scrollController,

View file

@ -90,7 +90,6 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
body: SafeArea(
child: Column(
children: [
// Top Navigation Bar
Padding(
padding: EdgeInsets.symmetric(
horizontal: topBarPaddingH,
@ -112,7 +111,6 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
),
),
// Skip button
TextButton(
onPressed: _skipTutorial,
style: TextButton.styleFrom(
@ -131,7 +129,6 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
),
),
// Main Content Area
Expanded(
child: PageView(
controller: _pageController,
@ -218,12 +215,10 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
),
),
// Bottom Control Area
Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Expressive Page Indicators
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_totalPages, (index) {
@ -246,7 +241,6 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
}),
),
SizedBox(height: bottomGap),
// Action Button
SizedBox(
width: double.infinity,
height: actionButtonHeight,
@ -402,7 +396,6 @@ class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Search Input
TextField(
controller: _controller,
onChanged: (value) {
@ -428,7 +421,6 @@ class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> {
),
),
// Result Placeholder
AnimatedSize(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutBack,
@ -541,7 +533,6 @@ class _InteractiveDownloadExampleState
_isCompleted = true;
});
// Reset after a delay
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() {

View file

@ -198,7 +198,7 @@ class FFmpegService {
final trimmedKey = decryptionKey.trim();
if (trimmedKey.isEmpty) return inputPath;
// Amazon encrypted streams are commonly MP4 container with FLAC audio.
// Encrypted streams are commonly MP4 container with FLAC audio.
// Prefer FLAC output to avoid MP4 muxing errors during decrypt copy.
final preferredExt = inputPath.toLowerCase().endsWith('.m4a')
? '.flac'
@ -217,7 +217,10 @@ class FFmpegService {
required String key,
}) {
final audioMap = mapAudioOnly ? '-map 0:a ' : '';
return '-v error -decryption_key "$key" -i "$inputPath" $audioMap-c copy "$outputPath" -y';
// Force MOV demuxer: -decryption_key is only supported by the MOV/MP4
// demuxer. The input may carry a .flac extension (SAF mode) while actually
// containing an encrypted M4A stream, so we must override auto-detection.
return '-v error -decryption_key "$key" -f mov -i "$inputPath" $audioMap-c copy "$outputPath" -y';
}
final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey);
@ -627,7 +630,7 @@ class FFmpegService {
return null;
}
static Future<LiveDecryptedStreamResult?> startAmazonLiveDecryptedStream({
static Future<LiveDecryptedStreamResult?> startEncryptedLiveDecryptedStream({
required String encryptedStreamUrl,
required String decryptionKey,
String preferredFormat = 'flac',
@ -1225,7 +1228,6 @@ class FFmpegService {
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = _buildOutputPath(inputPath, extension);
// Step 1: Convert audio
String command;
if (format == 'opus') {
command =
@ -1245,7 +1247,6 @@ class FFmpegService {
return null;
}
// Step 2: Embed metadata + cover into the converted file.
// Treat embed failure as conversion failure when metadata/cover was requested.
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
@ -1281,7 +1282,6 @@ class FFmpegService {
}
}
// Step 3: Delete original if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();

View file

@ -104,8 +104,6 @@ class HistoryDatabase {
}
}
// ==================== iOS Path Normalization ====================
/// Pattern to match iOS container paths
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
static final _iosContainerPattern = RegExp(
@ -325,8 +323,6 @@ class HistoryDatabase {
};
}
// ==================== CRUD Operations ====================
/// Insert or update a history item
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;

View file

@ -403,8 +403,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
// ==================== LYRICS PROVIDER SETTINGS ====================
/// Sets the lyrics provider order. Providers not in the list are disabled.
static Future<void> setLyricsProviders(List<String> providers) async {
final providersJSON = jsonEncode(providers);
@ -1060,8 +1058,6 @@ class PlatformBridge {
}
}
// ==================== LOCAL LIBRARY SCANNING ====================
/// Set the directory for caching extracted cover art
static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
_log.i('setLibraryCoverCacheDir: $cacheDir');
@ -1261,5 +1257,4 @@ class PlatformBridge {
await _channel.invokeMethod('clearStoreCache');
}
// ==================== YOUTUBE / COBALT ====================
}

Some files were not shown because too many files have changed in this diff Show more