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 # 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 ## [3.7.1] - 2026-03-06
### Added ### Added

View file

@ -6,7 +6,7 @@
<img src="icon.png" width="128" /> <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) ![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) ![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 ## FAQ
**Q: Why is my download failing with "Song not found"?** **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?** **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?** **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. 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) [![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 ## 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) [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 response = downloader(req.toString())
val respObj = JSONObject(response) val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) { 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_path", document.uri.toString())
respObj.put("file_name", document.name ?: fileName) respObj.put("file_name", document.name ?: fileName)
} else { } else {
@ -2239,13 +2260,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) 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 // Log methods
"getLogs" -> { "getLogs" -> {
val response = withContext(Dispatchers.IO) { 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" "strings"
) )
// AudioMetadata represents common audio file metadata
type AudioMetadata struct { type AudioMetadata struct {
Title string Title string
Artist string Artist string
@ -31,7 +30,6 @@ type AudioMetadata struct {
Comment string Comment string
} }
// MP3Quality represents MP3 specific quality info
type MP3Quality struct { type MP3Quality struct {
SampleRate int SampleRate int
BitDepth int BitDepth int
@ -39,7 +37,6 @@ type MP3Quality struct {
Bitrate int Bitrate int
} }
// OggQuality represents Ogg/Opus specific quality info
type OggQuality struct { type OggQuality struct {
SampleRate int SampleRate int
BitDepth int BitDepth int
@ -47,10 +44,6 @@ type OggQuality struct {
Bitrate int // estimated bitrate in bps Bitrate int // estimated bitrate in bps
} }
// =============================================================================
// ID3 Tag Reading (MP3)
// =============================================================================
func ReadID3Tags(filePath string) (*AudioMetadata, error) { func ReadID3Tags(filePath string) (*AudioMetadata, error) {
file, err := os.Open(filePath) file, err := os.Open(filePath)
if err != nil { if err != nil {
@ -1210,10 +1203,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
return 0 return 0
} }
// =============================================================================
// ID3v1 Genre List
// =============================================================================
var id3v1Genres = []string{ var id3v1Genres = []string{
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
@ -1244,10 +1233,6 @@ var id3v1Genres = []string{
"Thrash Metal", "Anime", "J-Pop", "Synthpop", "Thrash Metal", "Anime", "J-Pop", "Synthpop",
} }
// =============================================================================
// Cover Art Extraction
// =============================================================================
func extractMP3CoverArt(filePath string) ([]byte, string, error) { func extractMP3CoverArt(filePath string) ([]byte, string, error) {
file, err := os.Open(filePath) file, err := os.Open(filePath)
if err != nil { if err != nil {

View file

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

View file

@ -478,25 +478,6 @@ func DownloadTrack(requestJSON string) (string, error) {
} }
} }
err = 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,
}
}
err = amazonErr
case "deezer": case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req) deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil { if deezerErr == nil {
@ -640,7 +621,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
enrichRequestExtendedMetadata(&req) enrichRequestExtendedMetadata(&req)
allServices := []string{"tidal", "qobuz", "amazon", "deezer"} allServices := []string{"tidal", "qobuz", "deezer"}
preferredService := req.Service preferredService := req.Service
if preferredService == "" { if preferredService == "" {
preferredService = "tidal" preferredService = "tidal"
@ -707,27 +688,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
} }
err = qobuzErr err = qobuzErr
case "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": case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req) deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil { if deezerErr == nil {
@ -1579,11 +1539,6 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
return client.GetTidalURLFromDeezer(deezerTrackID) return client.GetTidalURLFromDeezer(deezerTrackID)
} }
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetAmazonURLFromDeezer(deezerTrackID)
}
func errorResponse(msg string) (string, error) { func errorResponse(msg string) (string, error) {
errorType := "unknown" errorType := "unknown"
lowerMsg := strings.ToLower(msg) lowerMsg := strings.ToLower(msg)
@ -2146,8 +2101,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== EXTENSION SYSTEM ====================
func InitExtensionSystem(extensionsDir, dataDir string) error { func InitExtensionSystem(extensionsDir, dataDir string) error {
manager := GetExtensionManager() manager := GetExtensionManager()
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil { if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
@ -2519,8 +2472,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== EXTENSION CUSTOM SEARCH ====================
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) { func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID) ext, err := manager.GetExtension(extensionID)
@ -3273,9 +3224,6 @@ func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second) return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
} }
// ==================== LOCAL LIBRARY SCANNING ====================
// SetLibraryCoverCacheDirJSON sets the directory for caching extracted cover art
func SetLibraryCoverCacheDirJSON(cacheDir string) { func SetLibraryCoverCacheDirJSON(cacheDir string) {
SetLibraryCoverCacheDir(cacheDir) SetLibraryCoverCacheDir(cacheDir)
} }
@ -3284,9 +3232,6 @@ func ScanLibraryFolderJSON(folderPath string) (string, error) {
return ScanLibraryFolder(folderPath) 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) { func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON) 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) return nil, fmt.Errorf("failed to read manifest.json: %w", err)
} }
// Parse and validate manifest
manifest, err := ParseManifest(manifestData) manifest, err := ParseManifest(manifestData)
if err != nil { if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err) 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 return nil
} }
// Only allows upgrades (new version > current version), not downgrades // Only allows upgrades (new version > current version), not downgrades
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) { func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") 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) 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) versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
if versionCompare < 0 { if versionCompare < 0 {
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version) 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) 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 extDataDir := existing.DataDir
extDir := existing.SourceDir extDir := existing.SourceDir
wasEnabled := existing.Enabled wasEnabled := existing.Enabled
@ -601,7 +592,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
SourceDir: extDir, SourceDir: extDir,
} }
// Initialize Goja VM
if err := m.initializeVM(ext); err != nil { if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error() ext.Error = err.Error()
ext.Enabled = false ext.Enabled = false
@ -626,7 +616,6 @@ type ExtensionUpgradeInfo struct {
} }
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) { func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") 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 { if !exists {
// Not installed - this is a new install, not upgrade
info.CurrentVersion = "" info.CurrentVersion = ""
info.CanUpgrade = false info.CanUpgrade = false
} else { } else {
@ -739,7 +727,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
permissions = append(permissions, "storage:enabled") permissions = append(permissions, "storage:enabled")
} }
// Determine status
status := "loaded" status := "loaded"
if ext.Error != "" { if ext.Error != "" {
status = "error" status = "error"
@ -940,7 +927,6 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
return nil, fmt.Errorf("extension is disabled") return nil, fmt.Errorf("extension is disabled")
} }
// Call the action function on the extension object
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.%s === '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 package gobackend
import ( import (

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@ -99,15 +100,16 @@ type ExtDownloadResult struct {
ErrorMessage string `json:"error_message,omitempty"` ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"` ErrorType string `json:"error_type,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"` Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"` Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"` AlbumArtist string `json:"album_artist,omitempty"`
TrackNumber int `json:"track_number,omitempty"` TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
ReleaseDate string `json:"release_date,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
} }
type ExtensionProviderWrapper struct { type ExtensionProviderWrapper struct {
@ -388,7 +390,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return &enrichedTrack, nil 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() { if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) 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(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === '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; return null;
})() })()
`, isrc, trackName, artistName) `, isrc, trackName, artistName, spotifyID, deezerID)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil { if err != nil {
@ -631,7 +633,7 @@ func GetProviderPriority() []string {
defer providerPriorityMu.RUnlock() defer providerPriorityMu.RUnlock()
if len(providerPriority) == 0 { if len(providerPriority) == 0 {
return []string{"tidal", "qobuz", "amazon", "deezer"} return []string{"tidal", "qobuz", "deezer"}
} }
result := make([]string, len(providerPriority)) result := make([]string, len(providerPriority))
@ -661,7 +663,7 @@ func GetMetadataProviderPriority() []string {
func isBuiltInProvider(providerID string) bool { func isBuiltInProvider(providerID string) bool {
switch providerID { switch providerID {
case "tidal", "qobuz", "amazon", "deezer": case "tidal", "qobuz", "deezer":
return true return true
default: default:
return false return false
@ -694,6 +696,27 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
priority = newPriority priority = newPriority
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority) GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
} else if !strictMode && req.Service != "" && !isBuiltInProvider(strings.ToLower(req.Service)) {
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 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) GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
outputPath := buildOutputPath(req) outputPath := buildOutputPathForExtension(req, ext)
if req.ItemID != "" { if req.ItemID != "" {
StartItemProgress(req.ItemID) StartItemProgress(req.ItemID)
} }
@ -813,6 +836,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Genre: req.Genre, Genre: req.Genre,
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey,
} }
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
@ -966,7 +990,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
provider := NewExtensionProviderWrapper(ext) 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 { if err != nil || !availability.Available {
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID) GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
if err != nil { if err != nil {
@ -975,7 +999,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue continue
} }
outputPath := buildOutputPath(req) outputPath := buildOutputPathForExtension(req, ext)
if req.ItemID != "" { if req.ItemID != "" {
StartItemProgress(req.ItemID) StartItemProgress(req.ItemID)
} }
@ -1011,6 +1035,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Genre: req.Genre, Genre: req.Genre,
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey,
} }
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
@ -1128,25 +1153,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
} }
} }
err = 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,
}
}
err = amazonErr
case "deezer": case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req) deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil { if deezerErr == nil {
@ -1226,7 +1232,58 @@ func buildOutputPath(req DownloadRequest) string {
ext = "." + ext 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) { func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
@ -1653,7 +1710,6 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu
}, nil }, nil
} }
// GetPostProcessingProviders returns all extensions that provide post-processing
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper { func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@ -1667,7 +1723,6 @@ func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrap
return providers return providers
} }
// RunPostProcessing runs all enabled post-processing hooks on a file
func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) { func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders() providers := m.GetPostProcessingProviders()
if len(providers) == 0 { if len(providers) == 0 {
@ -1713,7 +1768,6 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil 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) { func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders() providers := m.GetPostProcessingProviders()
if len(providers) == 0 { 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 return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
} }
// ==================== Lyrics Provider ====================
// ExtLyricsResult represents lyrics data returned from an extension
type ExtLyricsResult struct { type ExtLyricsResult struct {
Lines []ExtLyricsLine `json:"lines"` Lines []ExtLyricsLine `json:"lines"`
SyncType string `json:"syncType"` SyncType string `json:"syncType"`
@ -1785,7 +1836,6 @@ type ExtLyricsLine struct {
EndTimeMs int64 `json:"endTimeMs"` EndTimeMs int64 `json:"endTimeMs"`
} }
// FetchLyrics calls the extension's fetchLyrics function
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) { func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
if !p.extension.Manifest.IsLyricsProvider() { if !p.extension.Manifest.IsLyricsProvider() {
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID) 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 return response, nil
} }
// GetLyricsProviders returns all enabled extensions that provide lyrics
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper { func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()

View file

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

View file

@ -1,4 +1,3 @@
// Package gobackend provides FFmpeg API for extension runtime
package gobackend package gobackend
import ( import (
@ -10,9 +9,7 @@ import (
"github.com/dop251/goja" "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 { type FFmpegCommand struct {
ExtensionID string ExtensionID string
Command string Command string
@ -24,7 +21,6 @@ type FFmpegCommand struct {
Output string Output string
} }
// Global FFmpeg command queue
var ( var (
ffmpegCommands = make(map[string]*FFmpegCommand) ffmpegCommands = make(map[string]*FFmpegCommand)
ffmpegCommandsMu sync.RWMutex ffmpegCommandsMu sync.RWMutex

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
// Package gobackend provides Browser-like Polyfills for extension runtime
package gobackend package gobackend
import ( import (
@ -13,12 +12,10 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
// ==================== Browser-like Polyfills ====================
// These polyfills make porting browser/Node.js libraries easier // 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 { func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.createFetchError("URL is required") return r.createFetchError("URL is required")
@ -141,7 +138,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return responseObj return responseObj
} }
// createFetchError creates a fetch error response
func (r *ExtensionRuntime) createFetchError(message string) goja.Value { func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
errorObj := r.vm.NewObject() errorObj := r.vm.NewObject()
errorObj.Set("ok", false) errorObj.Set("ok", false)
@ -157,7 +153,6 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
return errorObj return errorObj
} }
// atobPolyfill implements browser atob() - decode base64 to string
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@ -174,7 +169,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded)) return r.vm.ToValue(string(decoded))
} }
// btoaPolyfill implements browser btoa() - encode string to base64
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") 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))) return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
} }
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) { func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object { vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
encoder := call.This 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) { func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
// JSON is already built-in to Goja, but we can enhance it
jsonScript := ` jsonScript := `
if (typeof JSON === 'undefined') { if (typeof JSON === 'undefined') {
var JSON = { var JSON = {

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
// Package gobackend provides timeout execution for extension JS code
package gobackend package gobackend
import ( 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 { blockingPatterns := []struct {
pattern string pattern string
reason string reason string
@ -532,7 +531,6 @@ func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
return false return false
} }
// extractDomain extracts the domain from a URL string
func extractDomain(rawURL string) string { func extractDomain(rawURL string) string {
if rawURL == "" { if rawURL == "" {
return "unknown" return "unknown"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,6 @@ type MusixmatchClient struct {
baseURL string baseURL string
} }
// Musixmatch proxy response models
type musixmatchSearchResponse struct { type musixmatchSearchResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
SongName string `json:"songName"` 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) 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) != "" { if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
if len(lines) > 0 { 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) != "" { if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
lines := plainTextLyricsLines(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) GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
} }
// Prefer synced lyrics
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
if len(lines) > 0 { 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) != "" { if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)

View file

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

View file

@ -17,7 +17,6 @@ type QQMusicClient struct {
httpClient *http.Client httpClient *http.Client
} }
// QQ Music search response models
type qqMusicSearchResponse struct { type qqMusicSearchResponse struct {
Data struct { Data struct {
Song struct { Song struct {
@ -184,7 +183,6 @@ func (c *QQMusicClient) FetchLyrics(
}, nil }, nil
} }
// Fall back to plain text
resultLines := plainTextLyricsLines(lrcText) resultLines := plainTextLyricsLines(lrcText)
if len(resultLines) > 0 { 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. // 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. // These packages are required by gomobile bind but not directly imported in code.
package gobackend package gobackend
import ( import (
// Required for gomobile bind to work
_ "golang.org/x/mobile/bind" _ "golang.org/x/mobile/bind"
) )

View file

@ -10,7 +10,6 @@ import (
type TrackIDCacheEntry struct { type TrackIDCacheEntry struct {
TidalTrackID int64 TidalTrackID int64
QobuzTrackID int64 QobuzTrackID int64
AmazonURL string
ExpiresAt time.Time 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() { func (c *TrackIDCache) Clear() {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@ -235,8 +215,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName) preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
case "qobuz": case "qobuz":
preWarmQobuzCache(r.ISRC, r.SpotifyID) preWarmQobuzCache(r.ISRC, r.SpotifyID)
case "amazon":
preWarmAmazonCache(r.ISRC, r.SpotifyID)
} }
}(req) }(req)
} }
@ -256,12 +234,10 @@ func preWarmTidalCache(isrc, _, _ string) {
// 1. From SongLink (fast, no Qobuz API call needed) // 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) // 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
func preWarmQobuzCache(isrc, spotifyID string) { func preWarmQobuzCache(isrc, spotifyID string) {
// First, try to get QobuzID from SongLink - this is faster and more reliable
if spotifyID != "" { if spotifyID != "" {
client := NewSongLinkClient() client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc) availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.QobuzID != "" { if err == nil && availability != nil && availability.QobuzID != "" {
// Parse QobuzID to int64
var trackID int64 var trackID int64
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { 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) 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() downloader := NewQobuzDownloader()
track, err := downloader.SearchTrackByISRC(isrc) track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil { 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 { func PreWarmCache(tracksJSON string) error {
var tracks []struct { var tracks []struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
package gobackend package gobackend
import ( 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) { func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
query := fmt.Sprintf("%s %s", artistName, trackName) query := fmt.Sprintf("%s %s", artistName, trackName)
searchQuery := url.QueryEscape(query) searchQuery := url.QueryEscape(query)
@ -213,7 +211,6 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
return resp, nil return resp, nil
} }
// requestCobaltDirect sends a download request to the primary Cobalt API.
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) { func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
reqBody := CobaltRequest{ reqBody := CobaltRequest{
URL: videoURL, URL: videoURL,
@ -470,7 +467,6 @@ func BuildYouTubeWatchURL(videoID string) string {
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID) 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 { func isYouTubeVideoID(s string) bool {
if len(s) != 11 { if len(s) != 11 {
return false return false
@ -707,7 +703,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
GoLog("[YouTube] Downloading to: %s\n", outputPath) GoLog("[YouTube] Downloading to: %s\n", outputPath)
// Parallel fetch cover art + lyrics
var parallelResult *ParallelDownloadResult var parallelResult *ParallelDownloadResult
if req.EmbedLyrics || req.CoverURL != "" { if req.EmbedLyrics || req.CoverURL != "" {
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n") 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 } if let error = error { throw error }
return response 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": case "preWarmTrackCache":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let tracksJson = args["tracks"] as! String let tracksJson = args["tracks"] as! String

View file

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

View file

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

View file

@ -763,7 +763,7 @@ abstract class AppLocalizations {
/// App description in header card /// App description in header card
/// ///
/// In en, this message translates to: /// 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; String get aboutAppDescription;
/// Section header for artist albums /// 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.'** /// **'If a track is not available on the first provider, the app will automatically try the next one.'**
String get providerPriorityInfo; String get providerPriorityInfo;
/// Label for built-in providers (Tidal/Qobuz/Amazon) /// Label for built-in providers (Tidal/Qobuz)
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Built-in'** /// **'Built-in'**
@ -3271,7 +3271,7 @@ abstract class AppLocalizations {
/// Tutorial welcome tip 2 /// Tutorial welcome tip 2
/// ///
/// In en, this message translates to: /// 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; String get tutorialWelcomeTip2;
/// Tutorial welcome tip 3 /// Tutorial welcome tip 3

View file

@ -365,7 +365,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get aboutAppDescription => 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 @override
String get artistAlbums => 'Alben'; String get artistAlbums => 'Alben';
@ -1826,7 +1826,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'FLAC-Qualität von Tidal, Qobuz oder Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>

View file

@ -356,7 +356,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get aboutAppDescription => 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 @override
String get artistAlbums => 'Albums'; String get artistAlbums => 'Albums';
@ -1809,7 +1809,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>

View file

@ -356,7 +356,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get aboutAppDescription => 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 @override
String get artistAlbums => 'Albums'; String get artistAlbums => 'Albums';
@ -1809,7 +1809,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@ -2705,7 +2705,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override @override
String get aboutAppDescription => 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 @override
String get artistAlbums => 'Álbumes'; String get artistAlbums => 'Álbumes';
@ -4150,7 +4150,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override @override
String get tutorialWelcomeTip2 => 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 @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>

View file

@ -358,7 +358,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get aboutAppDescription => 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 @override
String get artistAlbums => 'Albums'; String get artistAlbums => 'Albums';
@ -1811,7 +1811,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Audio en qualité FLAC depuis Tidal, Qobuz ou Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>

View file

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

View file

@ -359,7 +359,7 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get aboutAppDescription => 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 @override
String get artistAlbums => 'Album'; String get artistAlbums => 'Album';
@ -1816,7 +1816,7 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>

View file

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

View file

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

View file

@ -356,7 +356,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get aboutAppDescription => 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 @override
String get artistAlbums => 'Albums'; String get artistAlbums => 'Albums';
@ -1809,7 +1809,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Krijg FLAC-kwaliteit audio van Tidal, Qobuz of Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>

View file

@ -356,7 +356,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get aboutAppDescription => 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 @override
String get artistAlbums => 'Albums'; String get artistAlbums => 'Albums';
@ -1809,7 +1809,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@ -2705,7 +2705,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override @override
String get aboutAppDescription => 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 @override
String get artistAlbums => 'Álbuns'; String get artistAlbums => 'Álbuns';
@ -4147,7 +4147,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>

View file

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

View file

@ -361,7 +361,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get aboutAppDescription => 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 @override
String get artistAlbums => 'Albümler'; String get artistAlbums => 'Albümler';
@ -1821,7 +1821,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Tidal, Qobuz veya Deezer\'den FLAC kalitesinde ses alın';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>

View file

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

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "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": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@ -1089,7 +1089,7 @@
}, },
"providerBuiltIn": "Integriert", "providerBuiltIn": "Integriert",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Erweiterung", "providerExtension": "Erweiterung",
"@providerExtension": { "@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "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": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "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": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@ -1097,7 +1097,7 @@
}, },
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extension", "providerExtension": "Extension",
"@providerExtension": { "@providerExtension": {
@ -2383,7 +2383,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "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": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },

View file

@ -402,7 +402,7 @@
"@aboutDabMusicDesc": { "@aboutDabMusicDesc": {
"description": "Credit for DAB Music API" "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": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@ -1005,7 +1005,7 @@
}, },
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extension", "providerExtension": "Extension",
"@providerExtension": { "@providerExtension": {

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "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": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@ -1089,7 +1089,7 @@
}, },
"providerBuiltIn": "Integrado", "providerBuiltIn": "Integrado",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extensión", "providerExtension": "Extensión",
"@providerExtension": { "@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "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": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "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": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@ -1089,7 +1089,7 @@
}, },
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extension", "providerExtension": "Extension",
"@providerExtension": { "@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "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": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },

View file

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

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "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": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@ -1097,7 +1097,7 @@
}, },
"providerBuiltIn": "Bawaan", "providerBuiltIn": "Bawaan",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Ekstensi", "providerExtension": "Ekstensi",
"@providerExtension": { "@providerExtension": {
@ -2383,7 +2383,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "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": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },

View file

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

View file

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

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "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": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@ -1089,7 +1089,7 @@
}, },
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extension", "providerExtension": "Extension",
"@providerExtension": { "@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "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": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },

View file

@ -402,7 +402,7 @@
"@aboutDabMusicDesc": { "@aboutDabMusicDesc": {
"description": "Credit for DAB Music API" "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": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@ -1005,7 +1005,7 @@
}, },
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extension", "providerExtension": "Extension",
"@providerExtension": { "@providerExtension": {

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "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": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@ -1089,7 +1089,7 @@
}, },
"providerBuiltIn": "Embutido", "providerBuiltIn": "Embutido",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extensão", "providerExtension": "Extensão",
"@providerExtension": { "@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "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": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },

View file

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

View file

@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "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": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@ -1089,7 +1089,7 @@
}, },
"providerBuiltIn": "Dahili", "providerBuiltIn": "Dahili",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Eklenti", "providerExtension": "Eklenti",
"@providerExtension": { "@providerExtension": {
@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "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": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },

View file

@ -402,7 +402,7 @@
"@aboutDabMusicDesc": { "@aboutDabMusicDesc": {
"description": "Credit for DAB Music API" "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": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@ -1005,7 +1005,7 @@
}, },
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extension", "providerExtension": "Extension",
"@providerExtension": { "@providerExtension": {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -305,7 +305,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Full-screen cover background (no blur, full resolution)
if (widget.coverUrl != null) if (widget.coverUrl != null)
CachedNetworkImage( CachedNetworkImage(
imageUrl: imageUrl:
@ -326,7 +325,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
// Bottom gradient for readability
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
@ -345,7 +343,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
), ),
), ),
), ),
// Album info overlay at bottom
Positioned( Positioned(
left: 20, left: 20,
right: 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/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart';
/// Simple in-memory cache for artist data
class _ArtistCache { class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {}; static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10); static const Duration _ttl = Duration(minutes: 10);
@ -69,7 +68,6 @@ class _CacheEntry {
}); });
} }
/// Artist screen with Spotify-like design
class ArtistScreen extends ConsumerStatefulWidget { class ArtistScreen extends ConsumerStatefulWidget {
final String artistId; final String artistId;
final String artistName; final String artistName;
@ -717,7 +715,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
// Options
if (albums.isNotEmpty) if (albums.isNotEmpty)
_DiscographyOptionTile( _DiscographyOptionTile(
icon: Icons.library_music, icon: Icons.library_music,
@ -830,7 +827,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
int failedCount = 0; int failedCount = 0;
for (final album in albums) { for (final album in albums) {
if (!_isFetchingDiscography) break; // Cancelled if (!_isFetchingDiscography) break;
try { try {
final tracks = await _fetchAlbumTracks(album); final tracks = await _fetchAlbumTracks(album);
@ -1066,7 +1063,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
CachedNetworkImage( CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
alignment: Alignment.topCenter, // Show top of image (faces) alignment: Alignment.topCenter,
memCacheWidth: 800, memCacheWidth: 800,
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => placeholder: (context, url) =>
@ -1155,7 +1152,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
], ],
), ),
), ),
// Download Discography button (icon only, right-aligned)
if (hasDiscography && !_isSelectionMode) ...[ if (hasDiscography && !_isSelectionMode) ...[
const SizedBox(width: 12), const SizedBox(width: 12),
Container( Container(
@ -1201,7 +1197,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
); );
} }
/// Build Popular tracks section like Spotify
Widget _buildPopularSection(ColorScheme colorScheme) { Widget _buildPopularSection(ColorScheme colorScheme) {
if (_topTracks == null || _topTracks!.isEmpty) { if (_topTracks == null || _topTracks!.isEmpty) {
return const SizedBox.shrink(); 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 { void _handlePopularTrackTap(Track track, {required bool isQueued}) async {
if (isQueued) return; if (isQueued) return;
@ -1636,7 +1630,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
), ),
), ),
), ),
// Selection overlay
if (_isSelectionMode) if (_isSelectionMode)
Positioned.fill( Positioned.fill(
child: AnimatedContainer( child: AnimatedContainer(
@ -1652,7 +1645,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
), ),
), ),
), ),
// Checkbox
if (_isSelectionMode) if (_isSelectionMode)
Positioned( Positioned(
top: 8, 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/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
/// Screen to display downloaded tracks from a specific album
class DownloadedAlbumScreen extends ConsumerStatefulWidget { class DownloadedAlbumScreen extends ConsumerStatefulWidget {
final String albumName; final String albumName;
final String artistName; final String artistName;
@ -361,7 +360,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
); );
final tracks = _getAlbumTracks(allHistoryItems); final tracks = _getAlbumTracks(allHistoryItems);
// Show empty state if no tracks found
if (tracks.isEmpty) { if (tracks.isEmpty) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.albumName)), appBar: AppBar(title: Text(widget.albumName)),
@ -480,7 +478,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Full-screen cover background
if (embeddedCoverPath != null) if (embeddedCoverPath != null)
Image.file( Image.file(
File(embeddedCoverPath), File(embeddedCoverPath),
@ -508,7 +505,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
// Bottom gradient for readability
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
@ -527,7 +523,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
), ),
), ),
), ),
// Album info overlay at bottom
Positioned( Positioned(
left: 20, left: 20,
right: 20, right: 20,
@ -711,10 +706,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final discTracks = discMap[discNumber]; final discTracks = discMap[discNumber];
if (discTracks == null || discTracks.isEmpty) continue; if (discTracks == null || discTracks.isEmpty) continue;
// Add disc separator
children.add(_buildDiscSeparator(context, colorScheme, discNumber)); children.add(_buildDiscSeparator(context, colorScheme, discNumber));
// Add tracks for this disc
for (final track in discTracks) { for (final track in discTracks) {
children.add( children.add(
KeyedSubtree( KeyedSubtree(
@ -897,7 +890,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return; return;
} }
// Share SAF content URIs via native intent
if (safUris.isNotEmpty) { if (safUris.isNotEmpty) {
try { try {
if (safUris.length == 1) { if (safUris.length == 1) {
@ -908,13 +900,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} catch (_) {} } catch (_) {}
} }
// Share regular files via SharePlus
if (filesToShare.isNotEmpty) { if (filesToShare.isNotEmpty) {
await SharePlus.instance.share(ShareParams(files: filesToShare)); await SharePlus.instance.share(ShareParams(files: filesToShare));
} }
} }
/// Show batch convert bottom sheet
void _showBatchConvertSheet( void _showBatchConvertSheet(
BuildContext context, BuildContext context,
List<DownloadHistoryItem> allTracks, List<DownloadHistoryItem> allTracks,
@ -1388,7 +1378,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Action buttons row: Share, Convert
Row( Row(
children: [ children: [
Expanded( Expanded(

View file

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

View file

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

View file

@ -37,7 +37,6 @@ class _LibraryTracksFolderScreenState
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
// Multi-select state
bool _isSelectionMode = false; bool _isSelectionMode = false;
final Set<String> _selectedKeys = {}; final Set<String> _selectedKeys = {};
@ -145,8 +144,6 @@ class _LibraryTracksFolderScreenState
return url; return url;
} }
// Selection helpers
void _enterSelectionMode(String key) { void _enterSelectionMode(String key) {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
setState(() { setState(() {
@ -181,8 +178,6 @@ class _LibraryTracksFolderScreenState
}); });
} }
// Batch actions
Future<void> _removeSelected(List<CollectionTrackEntry> entries) async { Future<void> _removeSelected(List<CollectionTrackEntry> entries) async {
final keysToRemove = _selectedKeys.toSet(); final keysToRemove = _selectedKeys.toSet();
if (keysToRemove.isEmpty) return; if (keysToRemove.isEmpty) return;
@ -426,7 +421,6 @@ class _LibraryTracksFolderScreenState
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Drag handle
Container( Container(
width: 32, width: 32,
height: 4, height: 4,
@ -437,7 +431,6 @@ class _LibraryTracksFolderScreenState
), ),
), ),
// Header: [X close] [count] [Select All / Deselect]
Row( Row(
children: [ children: [
IconButton.filledTonal( IconButton.filledTonal(
@ -493,7 +486,6 @@ class _LibraryTracksFolderScreenState
const SizedBox(height: 12), const SizedBox(height: 12),
// Action buttons row
Row( Row(
children: [ children: [
if (isWishlist) if (isWishlist)
@ -525,7 +517,6 @@ class _LibraryTracksFolderScreenState
const SizedBox(height: 8), const SizedBox(height: 8),
// Remove button (full width, red)
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
@ -714,7 +705,6 @@ class _LibraryTracksFolderScreenState
) )
else else
coverFallback, coverFallback,
// Bottom gradient for readability
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
@ -733,7 +723,6 @@ class _LibraryTracksFolderScreenState
), ),
), ),
), ),
// Title and track count overlay
Positioned( Positioned(
left: 20, left: 20,
right: 20, right: 20,
@ -829,8 +818,6 @@ class _LibraryTracksFolderScreenState
); );
} }
// Header actions
Widget _buildHeaderActionPlaceholder() => const SizedBox(width: 48, height: 48); Widget _buildHeaderActionPlaceholder() => const SizedBox(width: 48, height: 48);
Widget _buildDownloadAllCenterButton(List<CollectionTrackEntry> entries) { Widget _buildDownloadAllCenterButton(List<CollectionTrackEntry> entries) {
@ -1263,7 +1250,6 @@ class _CollectionTrackTile extends ConsumerWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Header: drag handle + cover + track info
Column( Column(
children: [ children: [
const SizedBox(height: 8), 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/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
/// Screen to display tracks from a local library album
class LocalAlbumScreen extends ConsumerStatefulWidget { class LocalAlbumScreen extends ConsumerStatefulWidget {
final String albumName; final String albumName;
final String artistName; final String artistName;
@ -83,7 +82,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
List<LocalLibraryItem> _buildSortedTracks() { List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks); final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) { tracks.sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1; final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1; final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc); 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) { if (deletedCount == currentTracks.length) {
Navigator.pop(context); Navigator.pop(context);
} }
@ -233,7 +230,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final bottomPadding = MediaQuery.of(context).padding.bottom; final bottomPadding = MediaQuery.of(context).padding.bottom;
final tracks = _sortedTracksCache; final tracks = _sortedTracksCache;
// Show empty state if no tracks found
if (tracks.isEmpty) { if (tracks.isEmpty) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.albumName)), appBar: AppBar(title: Text(widget.albumName)),
@ -326,7 +322,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Full-screen cover background
if (widget.coverPath != null) if (widget.coverPath != null)
Image.file( Image.file(
File(widget.coverPath!), File(widget.coverPath!),
@ -343,7 +338,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
// Bottom gradient for readability
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
@ -362,7 +356,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
), ),
), ),
), ),
// Album info overlay at bottom
Positioned( Positioned(
left: 20, left: 20,
right: 20, right: 20,
@ -888,7 +881,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return false; return false;
} }
/// Batch re-enrich selected local tracks
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async { Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t}; final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[]; final selected = <LocalLibraryItem>[];
@ -988,7 +980,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
).showSnackBar(SnackBar(content: Text(summary))); ).showSnackBar(SnackBar(content: Text(summary)));
} }
/// Show batch convert bottom sheet
void _showBatchConvertSheet( void _showBatchConvertSheet(
BuildContext context, BuildContext context,
List<LocalLibraryItem> allTracks, List<LocalLibraryItem> allTracks,
@ -1261,7 +1252,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
String? safTempPath; String? safTempPath;
if (isSaf) { if (isSaf) {
// Copy SAF file to temp for conversion
safTempPath = await PlatformBridge.copyContentUriToTemp( safTempPath = await PlatformBridge.copyContentUriToTemp(
item.filePath, item.filePath,
); );
@ -1296,7 +1286,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (isSaf) { if (isSaf) {
// For SAF: derive the parent tree URI and relative dir from the content URI, // For SAF: derive the parent tree URI and relative dir from the content URI,
// then create new SAF file and delete old one // then create new SAF file and delete old one
//
// Parse the SAF URI to get the tree document path: // Parse the SAF URI to get the tree document path:
// content://...tree/...document/.../oldName.flac // content://...tree/...document/.../oldName.flac
// We need tree URI and relative dir to create the new file // We need tree URI and relative dir to create the new file
@ -1375,14 +1364,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
continue; continue;
} }
// Delete old SAF file
try { try {
await PlatformBridge.safDelete(item.filePath); await PlatformBridge.safDelete(item.filePath);
} catch (_) {} } catch (_) {}
await localDb.deleteByPath(item.filePath); await localDb.deleteByPath(item.filePath);
} }
// Clean up temp files
try { try {
await File(newPath).delete(); await File(newPath).delete();
} catch (_) {} } catch (_) {}
@ -1400,7 +1387,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} catch (_) {} } catch (_) {}
} }
// Reload local library to pick up converted files
ref.read(localLibraryProvider.notifier).reloadFromStorage(); ref.read(localLibraryProvider.notifier).reloadFromStorage();
_exitSelectionMode(); _exitSelectionMode();
@ -1513,7 +1499,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Action buttons row: Re-enrich, Convert
Row( Row(
children: [ children: [
Expanded( Expanded(

View file

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

View file

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

View file

@ -49,7 +49,7 @@ class AboutPage extends StatelessWidget {
title: Text( title: Text(
context.l10n.aboutTitle, context.l10n.aboutTitle,
style: TextStyle( style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28 fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),
@ -462,7 +462,6 @@ class _ContributorItem extends StatelessWidget {
} }
} }
/// Translator data model
class _Translator { class _Translator {
final String name; final String name;
final String crowdinUsername; final String crowdinUsername;
@ -477,7 +476,6 @@ class _Translator {
}); });
} }
/// Translators section with compact chip-style layout
class _TranslatorsSection extends StatelessWidget { class _TranslatorsSection extends StatelessWidget {
const _TranslatorsSection(); const _TranslatorsSection();
@ -558,7 +556,6 @@ class _TranslatorsSection extends StatelessWidget {
} }
} }
/// Individual translator chip
class _TranslatorChip extends StatelessWidget { class _TranslatorChip extends StatelessWidget {
final _Translator translator; 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 { class _ThemePreviewCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -423,7 +422,6 @@ class _ColorPaletteItem extends StatelessWidget {
} }
} }
/// Optimized app bar title with animation
class _AppBarTitle extends StatelessWidget { class _AppBarTitle extends StatelessWidget {
final String title; final String title;
final double topPadding; final double topPadding;
@ -440,14 +438,14 @@ class _AppBarTitle extends StatelessWidget {
final expandRatio = final expandRatio =
((constraints.maxHeight - minHeight) / (maxHeight - minHeight)) ((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar( return FlexibleSpaceBar(
expandedTitleScale: 1.0, expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text( title: Text(
title, title,
style: TextStyle( style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28 fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),

View file

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

View file

@ -23,7 +23,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
} }
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> { class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon', 'deezer']; static const _builtInServices = ['tidal', 'qobuz', 'deezer'];
static const _songLinkRegions = [ static const _songLinkRegions = [
'AD', 'AD',
'AE', 'AE',
@ -326,7 +326,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
((constraints.maxHeight - minHeight) / ((constraints.maxHeight - minHeight) /
(maxHeight - minHeight)) (maxHeight - minHeight))
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar( return FlexibleSpaceBar(
expandedTitleScale: 1.0, expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only( titlePadding: EdgeInsets.only(
@ -336,7 +336,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
title: Text( title: Text(
context.l10n.downloadTitle, context.l10n.downloadTitle,
style: TextStyle( style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28 fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),
@ -450,7 +450,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( 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 style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@ -720,7 +720,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
), ),
), ),
// Download Network Mode
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDownload), 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) ...[ if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader( child: SettingsSectionHeader(
@ -2039,7 +2037,7 @@ class _ServiceSelector extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider); final extState = ref.watch(extensionProvider);
final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'deezer', 'youtube']; final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube'];
final extensionProviders = extState.extensions final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider) .where((e) => e.enabled && e.hasDownloadProvider)
@ -2076,15 +2074,6 @@ class _ServiceSelector extends ConsumerWidget {
), ),
), ),
const SizedBox(width: 8), 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( Expanded(
child: _ServiceChip( child: _ServiceChip(
icon: Icons.smart_display, 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') { if (widget.setting.type == 'button') {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -198,7 +198,7 @@ class FFmpegService {
final trimmedKey = decryptionKey.trim(); final trimmedKey = decryptionKey.trim();
if (trimmedKey.isEmpty) return inputPath; 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. // Prefer FLAC output to avoid MP4 muxing errors during decrypt copy.
final preferredExt = inputPath.toLowerCase().endsWith('.m4a') final preferredExt = inputPath.toLowerCase().endsWith('.m4a')
? '.flac' ? '.flac'
@ -217,7 +217,10 @@ class FFmpegService {
required String key, required String key,
}) { }) {
final audioMap = mapAudioOnly ? '-map 0:a ' : ''; 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); final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey);
@ -627,7 +630,7 @@ class FFmpegService {
return null; return null;
} }
static Future<LiveDecryptedStreamResult?> startAmazonLiveDecryptedStream({ static Future<LiveDecryptedStreamResult?> startEncryptedLiveDecryptedStream({
required String encryptedStreamUrl, required String encryptedStreamUrl,
required String decryptionKey, required String decryptionKey,
String preferredFormat = 'flac', String preferredFormat = 'flac',
@ -1225,7 +1228,6 @@ class FFmpegService {
final extension = format == 'opus' ? '.opus' : '.mp3'; final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = _buildOutputPath(inputPath, extension); final outputPath = _buildOutputPath(inputPath, extension);
// Step 1: Convert audio
String command; String command;
if (format == 'opus') { if (format == 'opus') {
command = command =
@ -1245,7 +1247,6 @@ class FFmpegService {
return null; return null;
} }
// Step 2: Embed metadata + cover into the converted file.
// Treat embed failure as conversion failure when metadata/cover was requested. // Treat embed failure as conversion failure when metadata/cover was requested.
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty); final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
final hasCover = coverPath != null && coverPath.trim().isNotEmpty; final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
@ -1281,7 +1282,6 @@ class FFmpegService {
} }
} }
// Step 3: Delete original if requested
if (deleteOriginal) { if (deleteOriginal) {
try { try {
await File(inputPath).delete(); await File(inputPath).delete();

View file

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

View file

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

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