mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
v3.6.5: audio format conversion, PC v7.0.8 backend merge, Amazon re-enabled
This commit is contained in:
parent
bae2bf63eb
commit
fe1c96ea12
36 changed files with 3130 additions and 549 deletions
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -1,30 +1,56 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [3.6.1] - 2026-02-10
|
## [3.6.5] - 2026-02-10
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- **Audio Format Conversion**: Convert between FLAC, MP3, and Opus directly from Track Metadata screen with full metadata and cover art preservation
|
||||||
|
- **PC v7.0.8 Backend Merge**: Adopts several Go backend improvements from SpotiFLAC PC v7.0.8 including Amazon encrypted stream support, SpotFetch metadata fallback, and Qobuz API update
|
||||||
|
- **Amazon Music Re-enabled**: Amazon provider back in service with new API
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- "Use Primary Artist Only" setting: strips featured artists from folder names (e.g. "Justin Bieber, Quavo" becomes "Justin Bieber") for cleaner folder organization
|
- "Use Primary Artist Only" setting: strips featured artists from folder names (e.g. "Justin Bieber, Quavo" becomes "Justin Bieber") for cleaner folder organization
|
||||||
- Supports separators: `, ` `;` `&` `feat.` `ft.` `featuring` `with` `x`
|
- Supports separators: `, ` `;` `&` `feat.` `ft.` `featuring` `with` `x`
|
||||||
- Available in Settings > Download > below "Use Album Artist for folders"
|
- Available in Settings > Download > below "Use Album Artist for folders"
|
||||||
|
- Audio format conversion from Track Metadata screen
|
||||||
|
- Convert between FLAC, MP3, and Opus formats (any direction)
|
||||||
|
- Selectable bitrate: 128k, 192k, 256k, 320k
|
||||||
|
- Full metadata and cover art preservation during conversion
|
||||||
|
- Confirmation dialog before converting (original file deleted after)
|
||||||
|
- SAF storage support: copies to temp, converts, writes back via SAF
|
||||||
|
- Download history automatically updated with new file path
|
||||||
- Unified download request contract (`DownloadRequestPayload`) for all providers/flows
|
- Unified download request contract (`DownloadRequestPayload`) for all providers/flows
|
||||||
- Includes full superset fields: lyrics mode, genre/label/copyright, provider IDs, SAF params, cover/quality settings
|
- Includes full superset fields: lyrics mode, genre/label/copyright, provider IDs, SAF params, cover/quality settings
|
||||||
- Added strategy flags in payload: `use_extensions`, `use_fallback`
|
- Added strategy flags in payload: `use_extensions`, `use_fallback`
|
||||||
- New Go unified router entrypoint: `DownloadByStrategy(requestJSON)`
|
- New Go unified router entrypoint: `DownloadByStrategy(requestJSON)`
|
||||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||||
|
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||||
|
- New backend client for `spotify.afkarxyz.fun/api`
|
||||||
|
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||||
|
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||||
|
- Includes heuristic detection of lyrics stored in Comment fields
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Merged several Go backend improvements from SpotiFLAC PC v7.0.8: Amazon new API with encrypted stream/decryption support, SpotFetch metadata fallback for Spotify-blocked regions, multi-format lyrics extraction (MP3/Opus/OGG), Qobuz Jumo API update.
|
||||||
- Download queue execution now builds one payload and uses a single bridge entrypoint (`PlatformBridge.downloadByStrategy`) instead of branching into multiple bridge methods
|
- Download queue execution now builds one payload and uses a single bridge entrypoint (`PlatformBridge.downloadByStrategy`) instead of branching into multiple bridge methods
|
||||||
- Dart `downloadByStrategy` now sends a single request to Go (`downloadByStrategy` channel); routing concern is centralized in Go backend
|
- Dart `downloadByStrategy` now sends a single request to Go (`downloadByStrategy` channel); routing concern is centralized in Go backend
|
||||||
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||||
|
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||||
|
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||||
|
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||||
|
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||||
|
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed lyrics mode "External .LRC" still embedding lyrics into metadata - `lyrics_mode` was not being sent to Go backend for single-service downloads and YouTube provider, causing Go to default to "embed"
|
- Fixed lyrics mode "External .LRC" still embedding lyrics into metadata - `lyrics_mode` was not being sent to Go backend for single-service downloads and YouTube provider, causing Go to default to "embed"
|
||||||
- Fixed `flutter_local_notifications` v20 breaking changes - migrated all `initialize()`, `show()`, and `cancel()` calls from positional parameters to named parameters
|
- Fixed `flutter_local_notifications` v20 breaking changes - migrated all `initialize()`, `show()`, and `cancel()` calls from positional parameters to named parameters
|
||||||
- Fixed SAF duplicate folder bug: concurrent batch downloads creating empty folders with `(1)`, `(2)`, `(3)` suffixes - added synchronized lock to `ensureDocumentDir` in Kotlin with duplicate detection and cleanup
|
- Fixed SAF duplicate folder bug: concurrent batch downloads creating empty folders with `(1)`, `(2)`, `(3)` suffixes - added synchronized lock to `ensureDocumentDir` in Kotlin with duplicate detection and cleanup
|
||||||
|
- Track Metadata lyrics section now hides "Embed Lyrics" when lyrics are already embedded in file, preventing redundant embed attempts
|
||||||
|
- Fixed lyrics embed path to support FLAC/MP3/Opus consistently (including SAF files) without forcing unsupported parser paths
|
||||||
- Inconsistent parameter parity across download paths
|
- Inconsistent parameter parity across download paths
|
||||||
- `downloadWithExtensions` now carries `copyright`
|
- `downloadWithExtensions` now carries `copyright`
|
||||||
- YouTube path now carries `embed_max_quality_cover` and metadata parity fields
|
- YouTube path now carries `embed_max_quality_cover` and metadata parity fields
|
||||||
|
|
@ -37,10 +63,12 @@
|
||||||
|
|
||||||
- Centralized request serialization in `PlatformBridge` via shared invoke helper and unified payload model
|
- Centralized request serialization in `PlatformBridge` via shared invoke helper and unified payload model
|
||||||
- Go strategy router normalizes incoming service casing before dispatch
|
- Go strategy router normalizes incoming service casing before dispatch
|
||||||
- Verified integration after AAR refresh with:
|
- Extension runtime: `customSearch` now passes query/options via VM globals instead of string interpolation, preventing parser edge cases on certain devices
|
||||||
- `flutter analyze`
|
- Extension runtime: JS panic handler now logs full stack trace for easier debugging
|
||||||
- `go test -v ./...`
|
|
||||||
- Android Kotlin compile check (`:app:compileDebugKotlin`)
|
### Removed
|
||||||
|
|
||||||
|
- Buy Me a Coffee references removed from donate page, FUNDING.yml, README, and all localization files (account suspended)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ type AmazonDownloader struct {
|
||||||
var (
|
var (
|
||||||
globalAmazonDownloader *AmazonDownloader
|
globalAmazonDownloader *AmazonDownloader
|
||||||
amazonDownloaderOnce sync.Once
|
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
|
// AfkarXYZResponse is the response from AfkarXYZ API
|
||||||
|
|
@ -43,6 +45,12 @@ type AfkarXYZResponse struct {
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin}
|
||||||
|
type AmazonStreamResponse struct {
|
||||||
|
StreamURL string `json:"streamUrl"`
|
||||||
|
DecryptionKey string `json:"decryptionKey"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
amazonDownloaderOnce.Do(func() {
|
amazonDownloaderOnce.Do(func() {
|
||||||
globalAmazonDownloader = &AmazonDownloader{
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
|
|
@ -52,10 +60,9 @@ func NewAmazonDownloader() *AmazonDownloader {
|
||||||
return globalAmazonDownloader
|
return globalAmazonDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks
|
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
|
||||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) {
|
// Returns downloadURL, suggested fileName, optional decryptionKey.
|
||||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
|
|
@ -64,66 +71,184 @@ func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, st
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL)
|
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return downloadURL, fileName, nil
|
return downloadURL, fileName, decryptionKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
errStr := err.Error()
|
errStr := strings.ToLower(err.Error())
|
||||||
|
|
||||||
// Check if error is retryable
|
// Check if error is retryable
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||||
strings.Contains(errStr, "connection reset") ||
|
strings.Contains(errStr, "connection reset") ||
|
||||||
strings.Contains(errStr, "connection refused") ||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
strings.Contains(errStr, "EOF") ||
|
strings.Contains(errStr, "eof") ||
|
||||||
strings.Contains(errStr, "status 5") ||
|
strings.Contains(errStr, "status 5") ||
|
||||||
strings.Contains(errStr, "status 429")
|
strings.Contains(errStr, "status 429") ||
|
||||||
|
strings.Contains(errStr, "http 429")
|
||||||
|
|
||||||
if !isRetryable {
|
if !isRetryable {
|
||||||
return "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doAfkarXYZRequest performs a single request to AfkarXYZ API
|
func normalizeAmazonASIN(candidate string) string {
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) {
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
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()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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://amazon.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())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
|
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
|
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to read response: %w", err)
|
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiResp AfkarXYZResponse
|
var apiResp AfkarXYZResponse
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
return "", "", fmt.Errorf("failed to decode response: %w", err)
|
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !apiResp.Success || apiResp.Data.DirectLink == "" {
|
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
|
||||||
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
|
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
|
||||||
}
|
}
|
||||||
|
|
||||||
fileName := apiResp.Data.FileName
|
fileName := apiResp.Data.FileName
|
||||||
|
|
@ -134,19 +259,22 @@ func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, err
|
||||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||||
fileName = reg.ReplaceAllString(fileName, "")
|
fileName = reg.ReplaceAllString(fileName, "")
|
||||||
|
|
||||||
return apiResp.Data.DirectLink, fileName, nil
|
return apiResp.Data.DirectLink, fileName, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
|
||||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||||
|
|
||||||
downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL)
|
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decryptionKey != "" {
|
||||||
|
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
|
||||||
|
}
|
||||||
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
||||||
return downloadURL, fileName, nil
|
return downloadURL, fileName, decryptionKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||||
|
|
@ -233,17 +361,18 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD
|
||||||
|
|
||||||
// AmazonDownloadResult contains download result with quality info
|
// AmazonDownloadResult contains download result with quality info
|
||||||
type AmazonDownloadResult struct {
|
type AmazonDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
Album string
|
Album string
|
||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
LyricsLRC string
|
LyricsLRC string
|
||||||
|
DecryptionKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
|
|
@ -299,7 +428,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download using AfkarXYZ API
|
// Download using AfkarXYZ API
|
||||||
downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -321,7 +450,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
filename = sanitizeFilename(filename) + ".flac"
|
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
|
||||||
|
if outputExt == "" {
|
||||||
|
outputExt = ".flac"
|
||||||
|
}
|
||||||
|
filename = sanitizeFilename(filename) + outputExt
|
||||||
outputPath = filepath.Join(req.OutputDir, filename)
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
|
|
@ -352,6 +485,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
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
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
|
|
@ -360,7 +499,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
|
||||||
actualTrackNum := req.TrackNumber
|
actualTrackNum := req.TrackNumber
|
||||||
actualDiscNum := req.DiscNumber
|
actualDiscNum := req.DiscNumber
|
||||||
actualDate := req.ReleaseDate
|
actualDate := req.ReleaseDate
|
||||||
|
|
@ -368,25 +506,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
actualTitle := req.TrackName
|
actualTitle := req.TrackName
|
||||||
actualArtist := req.ArtistName
|
actualArtist := req.ArtistName
|
||||||
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
if !needsDecryption {
|
||||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
existingMeta, metaErr := ReadMetadata(actualOutputPath)
|
||||||
actualTrackNum = existingMeta.TrackNumber
|
if metaErr == nil && existingMeta != nil {
|
||||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
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)
|
||||||
}
|
}
|
||||||
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{
|
metadata := Metadata{
|
||||||
|
|
@ -409,7 +550,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
} else {
|
} else {
|
||||||
existingCover, coverErr := ExtractCoverArt(outputPath)
|
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
|
||||||
if coverErr == nil && len(existingCover) > 0 {
|
if coverErr == nil && len(existingCover) > 0 {
|
||||||
coverData = existingCover
|
coverData = existingCover
|
||||||
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
||||||
|
|
@ -418,11 +559,16 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isSafOutput {
|
if isSafOutput || needsDecryption {
|
||||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||||
} else {
|
} else {
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
|
||||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
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 != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
|
|
@ -433,20 +579,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
if lyricsMode == "external" || lyricsMode == "both" {
|
||||||
GoLog("[Amazon] Saving external LRC file...\n")
|
GoLog("[Amazon] Saving external LRC file...\n")
|
||||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
|
||||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
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 {
|
} else if req.EmbedLyrics {
|
||||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||||
|
|
@ -456,17 +604,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||||
|
|
||||||
quality := AudioQuality{}
|
quality := AudioQuality{}
|
||||||
if isSafOutput {
|
if isSafOutput || needsDecryption {
|
||||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
||||||
} else {
|
} else {
|
||||||
quality, err = GetAudioQuality(outputPath)
|
quality, err = GetAudioQuality(actualOutputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
|
||||||
if metaReadErr == nil && finalMeta != nil {
|
if metaReadErr == nil && finalMeta != nil {
|
||||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||||
|
|
@ -478,9 +626,10 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to ISRC index for fast duplicate checking
|
// Add to ISRC index for fast duplicate checking.
|
||||||
if !isSafOutput {
|
// When decryption is pending in Flutter, postpone indexing until final file is settled.
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
if !isSafOutput && !needsDecryption {
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
bitDepth := 0
|
bitDepth := 0
|
||||||
|
|
@ -496,16 +645,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return AmazonDownloadResult{
|
return AmazonDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: bitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
ReleaseDate: req.ReleaseDate,
|
ReleaseDate: req.ReleaseDate,
|
||||||
TrackNumber: actualTrackNum,
|
TrackNumber: actualTrackNum,
|
||||||
DiscNumber: actualDiscNum,
|
DiscNumber: actualDiscNum,
|
||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
LyricsLRC: lyricsLRC,
|
LyricsLRC: lyricsLRC,
|
||||||
|
DecryptionKey: decryptionKey,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
go_backend/amazon_asin_test.go
Normal file
46
go_backend/amazon_asin_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ type AudioMetadata struct {
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
|
Lyrics string
|
||||||
Label string
|
Label string
|
||||||
Copyright string
|
Copyright string
|
||||||
Composer string
|
Composer string
|
||||||
|
|
@ -181,6 +182,15 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
||||||
metadata.Label = value
|
metadata.Label = value
|
||||||
case "TCR":
|
case "TCR":
|
||||||
metadata.Copyright = value
|
metadata.Copyright = value
|
||||||
|
case "ULT":
|
||||||
|
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = v
|
||||||
|
}
|
||||||
|
case "TXX":
|
||||||
|
desc, userValue := extractUserTextFrame(frameData)
|
||||||
|
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = userValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pos += 6 + frameSize
|
pos += 6 + frameSize
|
||||||
|
|
@ -297,6 +307,15 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||||
if v := extractCommentFrame(frameData); v != "" {
|
if v := extractCommentFrame(frameData); v != "" {
|
||||||
metadata.Comment = v
|
metadata.Comment = v
|
||||||
}
|
}
|
||||||
|
case "USLT":
|
||||||
|
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = v
|
||||||
|
}
|
||||||
|
case "TXXX":
|
||||||
|
desc, userValue := extractUserTextFrame(frameData)
|
||||||
|
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = userValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pos += 10 + frameSize
|
pos += 10 + frameSize
|
||||||
|
|
@ -399,6 +418,98 @@ func extractCommentFrame(data []byte) string {
|
||||||
return extractTextFrame(framed)
|
return extractTextFrame(framed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractLyricsFrame parses ID3 unsynchronized lyrics frames (USLT/ULT).
|
||||||
|
// Format: encoding(1) + language(3) + description(null-terminated) + lyrics text.
|
||||||
|
func extractLyricsFrame(data []byte) string {
|
||||||
|
if len(data) < 5 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
encoding := data[0]
|
||||||
|
rest := data[4:] // skip 3-byte language code
|
||||||
|
|
||||||
|
var text []byte
|
||||||
|
switch encoding {
|
||||||
|
case 1, 2: // UTF-16 variants use double-null terminator
|
||||||
|
for i := 0; i+1 < len(rest); i += 2 {
|
||||||
|
if rest[i] == 0 && rest[i+1] == 0 {
|
||||||
|
text = rest[i+2:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: // ISO-8859-1 or UTF-8
|
||||||
|
idx := bytes.IndexByte(rest, 0)
|
||||||
|
if idx >= 0 && idx+1 < len(rest) {
|
||||||
|
text = rest[idx+1:]
|
||||||
|
} else {
|
||||||
|
text = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(text) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
framed := make([]byte, 1+len(text))
|
||||||
|
framed[0] = encoding
|
||||||
|
copy(framed[1:], text)
|
||||||
|
return extractTextFrame(framed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
|
||||||
|
// encoding(1) + description + separator + value.
|
||||||
|
func extractUserTextFrame(data []byte) (string, string) {
|
||||||
|
if len(data) < 2 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
encoding := data[0]
|
||||||
|
payload := data[1:]
|
||||||
|
|
||||||
|
var descRaw, valueRaw []byte
|
||||||
|
switch encoding {
|
||||||
|
case 1, 2: // UTF-16 variants
|
||||||
|
for i := 0; i+1 < len(payload); i += 2 {
|
||||||
|
if payload[i] == 0 && payload[i+1] == 0 {
|
||||||
|
descRaw = payload[:i]
|
||||||
|
valueRaw = payload[i+2:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: // ISO-8859-1 or UTF-8
|
||||||
|
idx := bytes.IndexByte(payload, 0)
|
||||||
|
if idx >= 0 {
|
||||||
|
descRaw = payload[:idx]
|
||||||
|
if idx+1 <= len(payload) {
|
||||||
|
valueRaw = payload[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(valueRaw) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
descFramed := make([]byte, 1+len(descRaw))
|
||||||
|
descFramed[0] = encoding
|
||||||
|
copy(descFramed[1:], descRaw)
|
||||||
|
|
||||||
|
valueFramed := make([]byte, 1+len(valueRaw))
|
||||||
|
valueFramed[0] = encoding
|
||||||
|
copy(valueFramed[1:], valueRaw)
|
||||||
|
|
||||||
|
return strings.TrimSpace(extractTextFrame(descFramed)), strings.TrimSpace(extractTextFrame(valueFramed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLyricsDescription(description string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(description)) {
|
||||||
|
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func decodeUTF16(data []byte) string {
|
func decodeUTF16(data []byte) string {
|
||||||
if len(data) < 2 {
|
if len(data) < 2 {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -843,6 +954,10 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||||
metadata.Composer = value
|
metadata.Composer = value
|
||||||
case "COMMENT", "DESCRIPTION":
|
case "COMMENT", "DESCRIPTION":
|
||||||
metadata.Comment = value
|
metadata.Comment = value
|
||||||
|
case "LYRICS", "UNSYNCEDLYRICS":
|
||||||
|
if metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = value
|
||||||
|
}
|
||||||
case "ORGANIZATION", "LABEL", "PUBLISHER":
|
case "ORGANIZATION", "LABEL", "PUBLISHER":
|
||||||
metadata.Label = value
|
metadata.Label = value
|
||||||
case "COPYRIGHT":
|
case "COPYRIGHT":
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,30 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if shouldTrySpotFetchFallback(err) {
|
||||||
|
data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
||||||
|
if apiErr == nil {
|
||||||
|
jsonBytes, marshalErr := json.Marshal(data)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return "", marshalErr
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if shouldTrySpotFetchFallback(err) {
|
||||||
|
fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
||||||
|
if apiErr == nil {
|
||||||
|
jsonBytes, marshalErr := json.Marshal(fallbackData)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return "", marshalErr
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,20 +198,22 @@ type DownloadResponse struct {
|
||||||
Copyright string `json:"copyright,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||||
|
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResult struct {
|
type DownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
Album string
|
Album string
|
||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
LyricsLRC string
|
LyricsLRC string
|
||||||
|
DecryptionKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDownloadSuccessResponse(
|
func buildDownloadSuccessResponse(
|
||||||
|
|
@ -258,6 +280,7 @@ func buildDownloadSuccessResponse(
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
LyricsLRC: result.LyricsLRC,
|
LyricsLRC: result.LyricsLRC,
|
||||||
|
DecryptionKey: result.DecryptionKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -323,17 +346,18 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
Title: amazonResult.Title,
|
Title: amazonResult.Title,
|
||||||
Artist: amazonResult.Artist,
|
Artist: amazonResult.Artist,
|
||||||
Album: amazonResult.Album,
|
Album: amazonResult.Album,
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
LyricsLRC: amazonResult.LyricsLRC,
|
LyricsLRC: amazonResult.LyricsLRC,
|
||||||
|
DecryptionKey: amazonResult.DecryptionKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
|
|
@ -534,17 +558,18 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
Title: amazonResult.Title,
|
Title: amazonResult.Title,
|
||||||
Artist: amazonResult.Artist,
|
Artist: amazonResult.Artist,
|
||||||
Album: amazonResult.Album,
|
Album: amazonResult.Album,
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
LyricsLRC: amazonResult.LyricsLRC,
|
LyricsLRC: amazonResult.LyricsLRC,
|
||||||
|
DecryptionKey: amazonResult.DecryptionKey,
|
||||||
}
|
}
|
||||||
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
||||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||||
|
|
@ -699,6 +724,7 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||||
result["track_number"] = meta.TrackNumber
|
result["track_number"] = meta.TrackNumber
|
||||||
result["disc_number"] = meta.DiscNumber
|
result["disc_number"] = meta.DiscNumber
|
||||||
result["isrc"] = meta.ISRC
|
result["isrc"] = meta.ISRC
|
||||||
|
result["lyrics"] = meta.Lyrics
|
||||||
result["genre"] = meta.Genre
|
result["genre"] = meta.Genre
|
||||||
result["composer"] = meta.Composer
|
result["composer"] = meta.Composer
|
||||||
result["comment"] = meta.Comment
|
result["comment"] = meta.Comment
|
||||||
|
|
@ -723,6 +749,7 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||||
result["track_number"] = meta.TrackNumber
|
result["track_number"] = meta.TrackNumber
|
||||||
result["disc_number"] = meta.DiscNumber
|
result["disc_number"] = meta.DiscNumber
|
||||||
result["isrc"] = meta.ISRC
|
result["isrc"] = meta.ISRC
|
||||||
|
result["lyrics"] = meta.Lyrics
|
||||||
result["genre"] = meta.Genre
|
result["genre"] = meta.Genre
|
||||||
result["composer"] = meta.Composer
|
result["composer"] = meta.Composer
|
||||||
result["comment"] = meta.Comment
|
result["comment"] = meta.Comment
|
||||||
|
|
@ -1178,9 +1205,12 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
var spotifyErr error
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
||||||
|
spotifyErr = err
|
||||||
} else {
|
} else {
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -1191,28 +1221,81 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
errStr := strings.ToLower(err.Error())
|
spotifyErr = err
|
||||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
if !shouldTrySpotFetchFallback(err) {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
||||||
|
if apiErr == nil {
|
||||||
|
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
|
||||||
|
jsonBytes, err := json.Marshal(spotFetchData)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
GoLog("[Fallback] SpotFetch API fallback failed: %v\n", apiErr)
|
||||||
|
|
||||||
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
if spotifyErr != nil {
|
||||||
|
return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
GoLog("[Fallback] Trying Deezer conversion fallback for %s...\n", parsed.Type)
|
||||||
|
|
||||||
if parsed.Type == "track" || parsed.Type == "album" {
|
if parsed.Type == "track" || parsed.Type == "album" {
|
||||||
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Type == "artist" {
|
if parsed.Type == "artist" {
|
||||||
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
if spotifyErr != nil {
|
||||||
|
return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
if spotifyErr != nil {
|
||||||
|
return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldTrySpotFetchFallback(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrNoSpotifyCredentials) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
indicators := []string{
|
||||||
|
"429",
|
||||||
|
"rate",
|
||||||
|
"limit",
|
||||||
|
"403",
|
||||||
|
"forbidden",
|
||||||
|
"401",
|
||||||
|
"unauthorized",
|
||||||
|
"timeout",
|
||||||
|
"connection",
|
||||||
|
"spotify error",
|
||||||
|
"access token",
|
||||||
|
"client token",
|
||||||
|
"eof",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, indicator := range indicators {
|
||||||
|
if strings.Contains(errStr, indicator) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||||
|
|
|
||||||
|
|
@ -1082,16 +1082,18 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
Title: amazonResult.Title,
|
Title: amazonResult.Title,
|
||||||
Artist: amazonResult.Artist,
|
Artist: amazonResult.Artist,
|
||||||
Album: amazonResult.Album,
|
Album: amazonResult.Album,
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
|
LyricsLRC: amazonResult.LyricsLRC,
|
||||||
|
DecryptionKey: amazonResult.DecryptionKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
|
|
@ -1119,6 +1121,8 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
|
LyricsLRC: result.LyricsLRC,
|
||||||
|
DecryptionKey: result.DecryptionKey,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1164,16 +1168,30 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
optionsJSON, _ := json.Marshal(options)
|
if options == nil {
|
||||||
|
options = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
// Avoid embedding user input directly into JS source. Some inputs can trigger
|
||||||
|
// parser/runtime edge cases on specific devices/Goja builds.
|
||||||
|
const queryVar = "__sf_custom_search_query"
|
||||||
|
const optionsVar = "__sf_custom_search_options"
|
||||||
|
global := p.vm.GlobalObject()
|
||||||
|
_ = global.Set(queryVar, query)
|
||||||
|
_ = global.Set(optionsVar, options)
|
||||||
|
defer func() {
|
||||||
|
global.Delete(queryVar)
|
||||||
|
global.Delete(optionsVar)
|
||||||
|
}()
|
||||||
|
|
||||||
|
const script = `
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
|
||||||
return extension.customSearch(%q, %s);
|
return extension.customSearch(__sf_custom_search_query, __sf_custom_search_options);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})()
|
})()
|
||||||
`, query, string(optionsJSON))
|
`
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1358,12 +1376,12 @@ type PostProcessResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostProcessInput struct {
|
type PostProcessInput struct {
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
URI string `json:"uri,omitempty"`
|
URI string `json:"uri,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
MimeType string `json:"mime_type,omitempty"`
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
Size int64 `json:"size,omitempty"`
|
Size int64 `json:"size,omitempty"`
|
||||||
IsSAF bool `json:"is_saf,omitempty"`
|
IsSAF bool `json:"is_saf,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostProcessTimeout = 2 * time.Minute
|
const PostProcessTimeout = 2 * time.Minute
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ package gobackend
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -49,6 +50,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}}
|
}}
|
||||||
} else {
|
} else {
|
||||||
|
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -475,33 +475,91 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExtractLyrics(filePath string) (string, error) {
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
|
lower := strings.ToLower(filePath)
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".flac") {
|
||||||
|
return extractLyricsFromFlac(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".mp3") {
|
||||||
|
meta, err := ReadID3Tags(filePath)
|
||||||
|
if err != nil || meta == nil {
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
return meta.Lyrics, nil
|
||||||
|
}
|
||||||
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||||
|
return meta.Comment, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||||
|
meta, err := ReadOggVorbisComments(filePath)
|
||||||
|
if err != nil || meta == nil {
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
return meta.Lyrics, nil
|
||||||
|
}
|
||||||
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||||
|
return meta.Comment, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("unsupported file format for lyrics extraction")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLyricsFromFlac(filePath string) (string, error) {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, meta := range f.Meta {
|
for _, meta := range f.Meta {
|
||||||
if meta.Type == flac.VorbisComment {
|
if meta.Type != flac.VorbisComment {
|
||||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
continue
|
||||||
if err != nil {
|
}
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lyrics, err := cmt.Get("LYRICS")
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err != nil {
|
||||||
return lyrics[0], nil
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
|
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||||
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("no lyrics found in file")
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func looksLikeEmbeddedLyrics(value string) bool {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if strings.Contains(lower, "[ar:") || strings.Contains(lower, "[ti:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(trimmed, "\n") && strings.Contains(trimmed, "[") && strings.Contains(trimmed, "]") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type AudioQuality struct {
|
type AudioQuality struct {
|
||||||
BitDepth int `json:"bit_depth"`
|
BitDepth int `json:"bit_depth"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
|
|
|
||||||
|
|
@ -419,7 +419,7 @@ func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
|
||||||
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
||||||
formatID := mapJumoQuality(quality)
|
formatID := mapJumoQuality(quality)
|
||||||
region := "US"
|
region := "US"
|
||||||
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
||||||
|
|
||||||
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
||||||
|
|
||||||
|
|
@ -428,6 +428,8 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
80
go_backend/spotfetch_api.go
Normal file
80
go_backend/spotfetch_api.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
||||||
|
|
||||||
|
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||||
|
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||||
|
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
|
||||||
|
parsed, err := parseSpotifyURI(spotifyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := strings.TrimSpace(apiBaseURL)
|
||||||
|
if base == "" {
|
||||||
|
base = DefaultSpotFetchAPIBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parsed.Type {
|
||||||
|
case "track":
|
||||||
|
var trackResp TrackResponse
|
||||||
|
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
||||||
|
}
|
||||||
|
return trackResp, nil
|
||||||
|
case "album":
|
||||||
|
var albumResp AlbumResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
||||||
|
}
|
||||||
|
return &albumResp, nil
|
||||||
|
case "playlist":
|
||||||
|
var playlistResp PlaylistResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
||||||
|
}
|
||||||
|
return playlistResp, nil
|
||||||
|
case "artist":
|
||||||
|
var artistResp ArtistResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
||||||
|
}
|
||||||
|
return &artistResp, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.6.1';
|
static const String version = '3.6.5';
|
||||||
static const String buildNumber = '78';
|
static const String buildNumber = '79';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5139,6 +5139,70 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Failed: {error}'**
|
/// **'Failed: {error}'**
|
||||||
String trackSaveFailed(String error);
|
String trackSaveFailed(String error);
|
||||||
|
|
||||||
|
/// Menu item - convert audio format
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert Format'**
|
||||||
|
String get trackConvertFormat;
|
||||||
|
|
||||||
|
/// Subtitle for convert format menu item
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert to MP3 or Opus'**
|
||||||
|
String get trackConvertFormatSubtitle;
|
||||||
|
|
||||||
|
/// Title of convert bottom sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert Audio'**
|
||||||
|
String get trackConvertTitle;
|
||||||
|
|
||||||
|
/// Label for format selection
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Target Format'**
|
||||||
|
String get trackConvertTargetFormat;
|
||||||
|
|
||||||
|
/// Label for bitrate selection
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bitrate'**
|
||||||
|
String get trackConvertBitrate;
|
||||||
|
|
||||||
|
/// Confirmation dialog title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Confirm Conversion'**
|
||||||
|
String get trackConvertConfirmTitle;
|
||||||
|
|
||||||
|
/// Confirmation dialog message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.'**
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Snackbar while converting
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Converting audio...'**
|
||||||
|
String get trackConvertConverting;
|
||||||
|
|
||||||
|
/// Snackbar after successful conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Converted to {format} successfully'**
|
||||||
|
String trackConvertSuccess(String format);
|
||||||
|
|
||||||
|
/// Snackbar when conversion fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Conversion failed'**
|
||||||
|
String get trackConvertFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2908,4 +2908,42 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2894,4 +2894,42 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2894,6 +2894,44 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||||
|
|
|
||||||
|
|
@ -2894,4 +2894,42 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2894,4 +2894,42 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2913,4 +2913,42 @@ class AppLocalizationsId extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Gagal: $error';
|
return 'Gagal: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Konversi Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Konversi ke MP3 atau Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Konversi Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Format Tujuan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Konfirmasi Konversi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Konversi dari $sourceFormat ke $targetFormat pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Mengkonversi audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Berhasil dikonversi ke $format';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Konversi gagal';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2880,4 +2880,42 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2894,4 +2894,42 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2894,4 +2894,42 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2894,6 +2894,44 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||||
|
|
|
||||||
|
|
@ -2940,4 +2940,42 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2909,4 +2909,42 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2894,6 +2894,44 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||||
|
|
|
||||||
|
|
@ -2184,5 +2184,38 @@
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"error": {"type": "String"}
|
"error": {"type": "String"}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
"trackConvertFormat": "Convert Format",
|
||||||
|
"@trackConvertFormat": {"description": "Menu item - convert audio format"},
|
||||||
|
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||||
|
"@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"},
|
||||||
|
"trackConvertTitle": "Convert Audio",
|
||||||
|
"@trackConvertTitle": {"description": "Title of convert bottom sheet"},
|
||||||
|
"trackConvertTargetFormat": "Target Format",
|
||||||
|
"@trackConvertTargetFormat": {"description": "Label for format selection"},
|
||||||
|
"trackConvertBitrate": "Bitrate",
|
||||||
|
"@trackConvertBitrate": {"description": "Label for bitrate selection"},
|
||||||
|
"trackConvertConfirmTitle": "Confirm Conversion",
|
||||||
|
"@trackConvertConfirmTitle": {"description": "Confirmation dialog title"},
|
||||||
|
"trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.",
|
||||||
|
"@trackConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {"type": "String"},
|
||||||
|
"targetFormat": {"type": "String"},
|
||||||
|
"bitrate": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertConverting": "Converting audio...",
|
||||||
|
"@trackConvertConverting": {"description": "Snackbar while converting"},
|
||||||
|
"trackConvertSuccess": "Converted to {format} successfully",
|
||||||
|
"@trackConvertSuccess": {
|
||||||
|
"description": "Snackbar after successful conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"format": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertFailed": "Conversion failed",
|
||||||
|
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -587,7 +587,7 @@
|
||||||
"aboutSupport": "Dukungan",
|
"aboutSupport": "Dukungan",
|
||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutApp": "Aplikasi",
|
"aboutApp": "Aplikasi",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|
@ -3206,5 +3206,38 @@
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"error": {"type": "String"}
|
"error": {"type": "String"}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
"trackConvertFormat": "Konversi Format",
|
||||||
|
"@trackConvertFormat": {"description": "Menu item - convert audio format"},
|
||||||
|
"trackConvertFormatSubtitle": "Konversi ke MP3 atau Opus",
|
||||||
|
"@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"},
|
||||||
|
"trackConvertTitle": "Konversi Audio",
|
||||||
|
"@trackConvertTitle": {"description": "Title of convert bottom sheet"},
|
||||||
|
"trackConvertTargetFormat": "Format Tujuan",
|
||||||
|
"@trackConvertTargetFormat": {"description": "Label for format selection"},
|
||||||
|
"trackConvertBitrate": "Bitrate",
|
||||||
|
"@trackConvertBitrate": {"description": "Label for bitrate selection"},
|
||||||
|
"trackConvertConfirmTitle": "Konfirmasi Konversi",
|
||||||
|
"@trackConvertConfirmTitle": {"description": "Confirmation dialog title"},
|
||||||
|
"trackConvertConfirmMessage": "Konversi dari {sourceFormat} ke {targetFormat} pada {bitrate}?\n\nFile asli akan dihapus setelah konversi.",
|
||||||
|
"@trackConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {"type": "String"},
|
||||||
|
"targetFormat": {"type": "String"},
|
||||||
|
"bitrate": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertConverting": "Mengkonversi audio...",
|
||||||
|
"@trackConvertConverting": {"description": "Snackbar while converting"},
|
||||||
|
"trackConvertSuccess": "Berhasil dikonversi ke {format}",
|
||||||
|
"@trackConvertSuccess": {
|
||||||
|
"description": "Snackbar after successful conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"format": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertFailed": "Konversi gagal",
|
||||||
|
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1237,6 +1237,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
}
|
}
|
||||||
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';
|
||||||
}
|
}
|
||||||
|
|
@ -2897,6 +2902,123 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
final actualService =
|
final actualService =
|
||||||
((result['service'] as String?)?.toLowerCase()) ??
|
((result['service'] as String?)?.toLowerCase()) ??
|
||||||
item.service.toLowerCase();
|
item.service.toLowerCase();
|
||||||
|
final decryptionKey =
|
||||||
|
(result['decryption_key'] as String?)?.trim() ?? '';
|
||||||
|
|
||||||
|
if (!wasExisting &&
|
||||||
|
decryptionKey.isNotEmpty &&
|
||||||
|
filePath != null &&
|
||||||
|
actualService == 'amazon') {
|
||||||
|
_log.i(
|
||||||
|
'Amazon encrypted stream detected, decrypting via FFmpeg...',
|
||||||
|
);
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.downloading,
|
||||||
|
progress: 0.9,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (effectiveSafMode && isContentUri(filePath)) {
|
||||||
|
final currentFilePath = filePath;
|
||||||
|
final tempPath = await _copySafToTemp(currentFilePath);
|
||||||
|
if (tempPath == null) {
|
||||||
|
_log.e('Failed to copy encrypted SAF file to temp for decrypt');
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.failed,
|
||||||
|
error: 'Failed to access encrypted SAF file',
|
||||||
|
errorType: DownloadErrorType.unknown,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? decryptedTempPath;
|
||||||
|
try {
|
||||||
|
decryptedTempPath = await FFmpegService.decryptAudioFile(
|
||||||
|
inputPath: tempPath,
|
||||||
|
decryptionKey: decryptionKey,
|
||||||
|
deleteOriginal: false,
|
||||||
|
);
|
||||||
|
if (decryptedTempPath == null) {
|
||||||
|
_log.e('FFmpeg decrypt failed for SAF file');
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.failed,
|
||||||
|
error: 'Failed to decrypt Amazon stream',
|
||||||
|
errorType: DownloadErrorType.unknown,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dotIndex = decryptedTempPath.lastIndexOf('.');
|
||||||
|
final decryptedExt = dotIndex >= 0
|
||||||
|
? decryptedTempPath.substring(dotIndex).toLowerCase()
|
||||||
|
: '.flac';
|
||||||
|
final allowedExt = <String>{'.flac', '.m4a', '.mp3', '.opus'};
|
||||||
|
final finalExt = allowedExt.contains(decryptedExt)
|
||||||
|
? decryptedExt
|
||||||
|
: '.flac';
|
||||||
|
|
||||||
|
final newFileName = '${safBaseName ?? 'track'}$finalExt';
|
||||||
|
final newUri = await _writeTempToSaf(
|
||||||
|
treeUri: settings.downloadTreeUri,
|
||||||
|
relativeDir: effectiveOutputDir,
|
||||||
|
fileName: newFileName,
|
||||||
|
mimeType: _mimeTypeForExt(finalExt),
|
||||||
|
srcPath: decryptedTempPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newUri == null) {
|
||||||
|
_log.e('Failed to write decrypted Amazon stream back to SAF');
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.failed,
|
||||||
|
error: 'Failed to write decrypted file to storage',
|
||||||
|
errorType: DownloadErrorType.unknown,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newUri != currentFilePath) {
|
||||||
|
await _deleteSafFile(currentFilePath);
|
||||||
|
}
|
||||||
|
filePath = newUri;
|
||||||
|
finalSafFileName = newFileName;
|
||||||
|
_log.i('Amazon SAF decryption completed');
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await File(tempPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
if (decryptedTempPath != null && decryptedTempPath != tempPath) {
|
||||||
|
try {
|
||||||
|
await File(decryptedTempPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final decryptedPath = await FFmpegService.decryptAudioFile(
|
||||||
|
inputPath: filePath,
|
||||||
|
decryptionKey: decryptionKey,
|
||||||
|
deleteOriginal: true,
|
||||||
|
);
|
||||||
|
if (decryptedPath == null) {
|
||||||
|
_log.e('FFmpeg decrypt failed for local file');
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.failed,
|
||||||
|
error: 'Failed to decrypt Amazon stream',
|
||||||
|
errorType: DownloadErrorType.unknown,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await deleteFile(filePath);
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filePath = decryptedPath;
|
||||||
|
_log.i('Amazon local decryption completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final isContentUriPath = filePath != null && isContentUri(filePath);
|
final isContentUriPath = filePath != null && isContentUri(filePath);
|
||||||
final mimeType = isContentUriPath
|
final mimeType = isContentUriPath
|
||||||
? await _getSafMimeType(filePath)
|
? await _getSafMimeType(filePath)
|
||||||
|
|
@ -3323,7 +3445,43 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
await File(tempPath).delete();
|
await File(tempPath).delete();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (!isContentUriPath &&
|
||||||
|
!effectiveSafMode &&
|
||||||
|
isFlacFile &&
|
||||||
|
!wasExisting &&
|
||||||
|
actualService == 'amazon' &&
|
||||||
|
decryptionKey.isNotEmpty) {
|
||||||
|
_log.d(
|
||||||
|
'Local FLAC after Amazon decrypt detected, embedding metadata and cover...',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.downloading,
|
||||||
|
progress: 0.99,
|
||||||
|
);
|
||||||
|
|
||||||
|
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||||
|
trackToDownload,
|
||||||
|
result,
|
||||||
|
normalizedAlbumArtist,
|
||||||
|
);
|
||||||
|
final backendGenre = result['genre'] as String?;
|
||||||
|
final backendLabel = result['label'] as String?;
|
||||||
|
final backendCopyright = result['copyright'] as String?;
|
||||||
|
|
||||||
|
await _embedMetadataAndCover(
|
||||||
|
filePath,
|
||||||
|
finalTrack,
|
||||||
|
genre: backendGenre ?? genre,
|
||||||
|
label: backendLabel ?? label,
|
||||||
|
copyright: backendCopyright,
|
||||||
|
);
|
||||||
|
_log.d('Local FLAC metadata embedding completed');
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Local FLAC metadata embedding failed: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt
|
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt
|
||||||
|
|
|
||||||
|
|
@ -646,13 +646,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getQualityBadgeText(String quality) {
|
String _getQualityBadgeText(String quality) {
|
||||||
if (quality.contains('bit')) {
|
final q = quality.trim().toLowerCase();
|
||||||
|
if (q.contains('bit')) {
|
||||||
return quality.split('/').first;
|
return quality.split('/').first;
|
||||||
}
|
}
|
||||||
final bitrateMatch = RegExp(r'(\d+)kbps').firstMatch(quality);
|
|
||||||
if (bitrateMatch != null) {
|
// Supports "MP3 320k", "Opus 256kbps", etc.
|
||||||
return '${bitrateMatch.group(1)}k';
|
final bitrateTextMatch = RegExp(
|
||||||
|
r'(\d+)\s*k(?:bps)?',
|
||||||
|
caseSensitive: false,
|
||||||
|
).firstMatch(quality);
|
||||||
|
if (bitrateTextMatch != null) {
|
||||||
|
return '${bitrateTextMatch.group(1)}k';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Supports legacy quality IDs like "opus_256" / "mp3_320".
|
||||||
|
final bitrateIdMatch = RegExp(r'_(\d+)$').firstMatch(q);
|
||||||
|
if (bitrateIdMatch != null) {
|
||||||
|
return '${bitrateIdMatch.group(1)}k';
|
||||||
|
}
|
||||||
|
|
||||||
return quality.split(' ').first;
|
return quality.split(' ').first;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1647,7 +1660,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
),
|
),
|
||||||
|
|
||||||
// Search bar - always at top
|
// Search bar - always at top
|
||||||
if (allHistoryItems.isNotEmpty || hasQueueItems || localLibraryItems.isNotEmpty)
|
if (allHistoryItems.isNotEmpty ||
|
||||||
|
hasQueueItems ||
|
||||||
|
localLibraryItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
|
@ -2946,13 +2961,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
// show bytes downloaded instead of percentage
|
// show bytes downloaded instead of percentage
|
||||||
item.progress > 0
|
item.progress > 0
|
||||||
? (item.speedMBps > 0
|
? (item.speedMBps > 0
|
||||||
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||||
: '${(item.progress * 100).toStringAsFixed(0)}%')
|
: '${(item.progress * 100).toStringAsFixed(0)}%')
|
||||||
: (item.bytesReceived > 0
|
: (item.bytesReceived > 0
|
||||||
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||||
: (item.speedMBps > 0
|
: (item.speedMBps > 0
|
||||||
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||||
: 'Starting...')),
|
: 'Starting...')),
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,7 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_DonorTile(name: 'J', colorScheme: colorScheme),
|
_DonorTile(name: 'J', colorScheme: colorScheme),
|
||||||
_DonorTile(name: 'Julian', colorScheme: colorScheme),
|
_DonorTile(name: 'Julian', colorScheme: colorScheme),
|
||||||
|
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
|
||||||
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
||||||
_DonorTile(
|
_DonorTile(
|
||||||
name: '283Fabio',
|
name: '283Fabio',
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
static const _builtInServices = ['tidal', 'qobuz'];
|
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||||
int _androidSdkVersion = 0;
|
int _androidSdkVersion = 0;
|
||||||
bool _hasAllFilesAccess = false;
|
bool _hasAllFilesAccess = false;
|
||||||
|
|
||||||
|
|
@ -248,7 +248,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Select Tidal or Qobuz above to configure quality',
|
'Select Tidal, Qobuz, or Amazon above to configure quality',
|
||||||
style: Theme.of(context).textTheme.bodySmall
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
|
@ -1366,6 +1366,7 @@ class _ServiceSelector extends ConsumerWidget {
|
||||||
final isExtensionService = ![
|
final isExtensionService = ![
|
||||||
'tidal',
|
'tidal',
|
||||||
'qobuz',
|
'qobuz',
|
||||||
|
'amazon',
|
||||||
].contains(currentService);
|
].contains(currentService);
|
||||||
final isCurrentExtensionEnabled = isExtensionService
|
final isCurrentExtensionEnabled = isExtensionService
|
||||||
? extensionProviders.any((e) => e.id == currentService)
|
? extensionProviders.any((e) => e.id == currentService)
|
||||||
|
|
@ -1392,6 +1393,13 @@ class _ServiceSelector extends ConsumerWidget {
|
||||||
isSelected: effectiveService == 'qobuz',
|
isSelected: effectiveService == 'qobuz',
|
||||||
onTap: () => onChanged('qobuz'),
|
onTap: () => onChanged('qobuz'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(
|
||||||
|
icon: Icons.shopping_bag_outlined,
|
||||||
|
label: 'Amazon',
|
||||||
|
isSelected: effectiveService == 'amazon',
|
||||||
|
onTap: () => onChanged('amazon'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (extensionProviders.isNotEmpty) ...[
|
if (extensionProviders.isNotEmpty) ...[
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -56,6 +56,48 @@ class FFmpegService {
|
||||||
return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt';
|
return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<String> _buildDecryptionKeyCandidates(String rawKey) {
|
||||||
|
final candidates = <String>[];
|
||||||
|
|
||||||
|
void addCandidate(String key) {
|
||||||
|
final normalized = key.trim();
|
||||||
|
if (normalized.isEmpty) return;
|
||||||
|
if (!candidates.contains(normalized)) {
|
||||||
|
candidates.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final trimmed = rawKey.trim();
|
||||||
|
if (trimmed.isEmpty) return candidates;
|
||||||
|
|
||||||
|
addCandidate(trimmed);
|
||||||
|
|
||||||
|
final noPrefix = trimmed.startsWith(RegExp(r'0x', caseSensitive: false))
|
||||||
|
? trimmed.substring(2)
|
||||||
|
: trimmed;
|
||||||
|
addCandidate(noPrefix);
|
||||||
|
|
||||||
|
final compactHex = noPrefix.replaceAll(RegExp(r'[^0-9a-fA-F]'), '');
|
||||||
|
if (compactHex.isNotEmpty && compactHex.length.isEven) {
|
||||||
|
addCandidate(compactHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final b64 = noPrefix.replaceAll(RegExp(r'\s+'), '');
|
||||||
|
final decoded = base64Decode(b64);
|
||||||
|
if (decoded.isNotEmpty) {
|
||||||
|
final hex = decoded
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
if (hex.isNotEmpty) {
|
||||||
|
addCandidate(hex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<FFmpegResult> _execute(String command) async {
|
static Future<FFmpegResult> _execute(String command) async {
|
||||||
try {
|
try {
|
||||||
final session = await FFmpegKit.execute(command);
|
final session = await FFmpegKit.execute(command);
|
||||||
|
|
@ -77,7 +119,7 @@ class FFmpegService {
|
||||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||||
|
|
||||||
final command =
|
final command =
|
||||||
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
'-v error -xerror -i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||||
|
|
||||||
final result = await _execute(command);
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
|
@ -133,6 +175,111 @@ class FFmpegService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<String?> decryptAudioFile({
|
||||||
|
required String inputPath,
|
||||||
|
required String decryptionKey,
|
||||||
|
bool deleteOriginal = true,
|
||||||
|
}) async {
|
||||||
|
final trimmedKey = decryptionKey.trim();
|
||||||
|
if (trimmedKey.isEmpty) return inputPath;
|
||||||
|
|
||||||
|
// Amazon encrypted streams are commonly MP4 container with FLAC audio.
|
||||||
|
// Prefer FLAC output to avoid MP4 muxing errors during decrypt copy.
|
||||||
|
final preferredExt = inputPath.toLowerCase().endsWith('.m4a')
|
||||||
|
? '.flac'
|
||||||
|
: inputPath.toLowerCase().endsWith('.flac')
|
||||||
|
? '.flac'
|
||||||
|
: inputPath.toLowerCase().endsWith('.mp3')
|
||||||
|
? '.mp3'
|
||||||
|
: inputPath.toLowerCase().endsWith('.opus')
|
||||||
|
? '.opus'
|
||||||
|
: '.flac';
|
||||||
|
var tempOutput = _buildOutputPath(inputPath, preferredExt);
|
||||||
|
|
||||||
|
String buildDecryptCommand(
|
||||||
|
String outputPath, {
|
||||||
|
required bool mapAudioOnly,
|
||||||
|
required String key,
|
||||||
|
}) {
|
||||||
|
final audioMap = mapAudioOnly ? '-map 0:a ' : '';
|
||||||
|
return '-v error -decryption_key "$key" -i "$inputPath" $audioMap-c copy "$outputPath" -y';
|
||||||
|
}
|
||||||
|
|
||||||
|
final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey);
|
||||||
|
if (keyCandidates.isEmpty) {
|
||||||
|
_log.e('No usable decryption key candidates');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
FFmpegResult? lastResult;
|
||||||
|
var decryptSucceeded = false;
|
||||||
|
|
||||||
|
for (final keyCandidate in keyCandidates) {
|
||||||
|
_log.d(
|
||||||
|
'Executing FFmpeg decrypt command (key length: ${keyCandidate.length})',
|
||||||
|
);
|
||||||
|
var result = await _execute(
|
||||||
|
buildDecryptCommand(
|
||||||
|
tempOutput,
|
||||||
|
mapAudioOnly: preferredExt == '.flac',
|
||||||
|
key: keyCandidate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback for uncommon streams that cannot be remuxed into FLAC.
|
||||||
|
if (!result.success && preferredExt == '.flac') {
|
||||||
|
final fallbackOutput = _buildOutputPath(inputPath, '.m4a');
|
||||||
|
final fallbackResult = await _execute(
|
||||||
|
buildDecryptCommand(
|
||||||
|
fallbackOutput,
|
||||||
|
mapAudioOnly: false,
|
||||||
|
key: keyCandidate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (fallbackResult.success) {
|
||||||
|
tempOutput = fallbackOutput;
|
||||||
|
result = fallbackResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
decryptSucceeded = true;
|
||||||
|
lastResult = result;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
lastResult = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decryptSucceeded) {
|
||||||
|
_log.e('FFmpeg decrypt failed: ${lastResult?.output ?? 'unknown error'}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
final inputFile = File(inputPath);
|
||||||
|
if (!await tempFile.exists()) {
|
||||||
|
_log.e('Decrypted output file not found: $tempOutput');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteOriginal && await inputFile.exists()) {
|
||||||
|
await inputFile.delete();
|
||||||
|
}
|
||||||
|
return tempOutput;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to finalize decrypted file: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<String?> convertFlacToMp3(
|
static Future<String?> convertFlacToMp3(
|
||||||
String inputPath, {
|
String inputPath, {
|
||||||
String bitrate = '320k',
|
String bitrate = '320k',
|
||||||
|
|
@ -616,6 +763,97 @@ class FFmpegService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unified audio format conversion with full metadata + cover preservation.
|
||||||
|
/// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format).
|
||||||
|
/// Returns the new file path on success, null on failure.
|
||||||
|
static Future<String?> convertAudioFormat({
|
||||||
|
required String inputPath,
|
||||||
|
required String targetFormat,
|
||||||
|
required String bitrate,
|
||||||
|
required Map<String, String> metadata,
|
||||||
|
String? coverPath,
|
||||||
|
bool deleteOriginal = true,
|
||||||
|
}) async {
|
||||||
|
final format = targetFormat.toLowerCase();
|
||||||
|
if (format != 'mp3' && format != 'opus') {
|
||||||
|
_log.e('Unsupported target format: $targetFormat');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final extension = format == 'opus' ? '.opus' : '.mp3';
|
||||||
|
final outputPath = _buildOutputPath(inputPath, extension);
|
||||||
|
|
||||||
|
// Step 1: Convert audio
|
||||||
|
String command;
|
||||||
|
if (format == 'opus') {
|
||||||
|
command =
|
||||||
|
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
|
||||||
|
} else {
|
||||||
|
command =
|
||||||
|
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 "$outputPath" -y';
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i(
|
||||||
|
'Converting ${inputPath.split(Platform.pathSeparator).last} to $format @ $bitrate',
|
||||||
|
);
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
_log.e('Audio conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Embed metadata + cover into the converted file.
|
||||||
|
// Treat embed failure as conversion failure when metadata/cover was requested.
|
||||||
|
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
|
||||||
|
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
|
||||||
|
if (hasMetadata || hasCover) {
|
||||||
|
String? embedResult;
|
||||||
|
if (format == 'mp3') {
|
||||||
|
embedResult = await embedMetadataToMp3(
|
||||||
|
mp3Path: outputPath,
|
||||||
|
coverPath: coverPath,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
embedResult = await embedMetadataToOpus(
|
||||||
|
opusPath: outputPath,
|
||||||
|
coverPath: coverPath,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedResult == null) {
|
||||||
|
_log.e(
|
||||||
|
'Metadata/Cover preservation failed, rolling back converted file',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final out = File(outputPath);
|
||||||
|
if (await out.exists()) {
|
||||||
|
await out.delete();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to cleanup failed converted file: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Delete original if requested
|
||||||
|
if (deleteOriginal) {
|
||||||
|
try {
|
||||||
|
await File(inputPath).delete();
|
||||||
|
_log.i(
|
||||||
|
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to delete original: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
static Map<String, String> _convertToId3Tags(
|
static Map<String, String> _convertToId3Tags(
|
||||||
Map<String, String> vorbisMetadata,
|
Map<String, String> vorbisMetadata,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -17,21 +17,21 @@ String? _currentContainerPath;
|
||||||
class HistoryDatabase {
|
class HistoryDatabase {
|
||||||
static final HistoryDatabase instance = HistoryDatabase._init();
|
static final HistoryDatabase instance = HistoryDatabase._init();
|
||||||
static Database? _database;
|
static Database? _database;
|
||||||
|
|
||||||
HistoryDatabase._init();
|
HistoryDatabase._init();
|
||||||
|
|
||||||
Future<Database> get database async {
|
Future<Database> get database async {
|
||||||
if (_database != null) return _database!;
|
if (_database != null) return _database!;
|
||||||
_database = await _initDB('history.db');
|
_database = await _initDB('history.db');
|
||||||
return _database!;
|
return _database!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Database> _initDB(String fileName) async {
|
Future<Database> _initDB(String fileName) async {
|
||||||
final dbPath = await getApplicationDocumentsDirectory();
|
final dbPath = await getApplicationDocumentsDirectory();
|
||||||
final path = join(dbPath.path, fileName);
|
final path = join(dbPath.path, fileName);
|
||||||
|
|
||||||
_log.i('Initializing database at: $path');
|
_log.i('Initializing database at: $path');
|
||||||
|
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 3,
|
version: 3,
|
||||||
|
|
@ -39,10 +39,10 @@ class HistoryDatabase {
|
||||||
onUpgrade: _upgradeDB,
|
onUpgrade: _upgradeDB,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _createDB(Database db, int version) async {
|
Future<void> _createDB(Database db, int version) async {
|
||||||
_log.i('Creating database schema v$version');
|
_log.i('Creating database schema v$version');
|
||||||
|
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE history (
|
CREATE TABLE history (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|
@ -73,16 +73,20 @@ class HistoryDatabase {
|
||||||
copyright TEXT
|
copyright TEXT
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
// Indexes for fast lookups
|
// Indexes for fast lookups
|
||||||
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
|
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
|
||||||
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
|
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
|
||||||
await db.execute('CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)');
|
await db.execute(
|
||||||
await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)');
|
'CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_album ON history(album_name, album_artist)',
|
||||||
|
);
|
||||||
|
|
||||||
_log.i('Database schema created with indexes');
|
_log.i('Database schema created with indexes');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
|
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
|
||||||
_log.i('Upgrading database from v$oldVersion to v$newVersion');
|
_log.i('Upgrading database from v$oldVersion to v$newVersion');
|
||||||
if (oldVersion < 2) {
|
if (oldVersion < 2) {
|
||||||
|
|
@ -95,20 +99,20 @@ class HistoryDatabase {
|
||||||
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
|
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== iOS Path Normalization ====================
|
// ==================== 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(
|
||||||
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
|
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Initialize and cache the current iOS container path
|
/// Initialize and cache the current iOS container path
|
||||||
Future<void> _initContainerPath() async {
|
Future<void> _initContainerPath() async {
|
||||||
if (!Platform.isIOS || _currentContainerPath != null) return;
|
if (!Platform.isIOS || _currentContainerPath != null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final docDir = await getApplicationDocumentsDirectory();
|
final docDir = await getApplicationDocumentsDirectory();
|
||||||
// Extract container path up to and including the UUID folder
|
// Extract container path up to and including the UUID folder
|
||||||
|
|
@ -122,55 +126,58 @@ class HistoryDatabase {
|
||||||
_log.w('Failed to get iOS container path: $e');
|
_log.w('Failed to get iOS container path: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize iOS file path by replacing old container UUID with current one
|
/// Normalize iOS file path by replacing old container UUID with current one
|
||||||
/// This fixes the issue where iOS changes container UUID after app updates
|
/// This fixes the issue where iOS changes container UUID after app updates
|
||||||
String _normalizeIosPath(String? filePath) {
|
String _normalizeIosPath(String? filePath) {
|
||||||
if (filePath == null || filePath.isEmpty) return filePath ?? '';
|
if (filePath == null || filePath.isEmpty) return filePath ?? '';
|
||||||
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
|
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
|
||||||
|
|
||||||
// Check if path contains an iOS container path
|
// Check if path contains an iOS container path
|
||||||
if (_iosContainerPattern.hasMatch(filePath)) {
|
if (_iosContainerPattern.hasMatch(filePath)) {
|
||||||
final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!);
|
final normalized = filePath.replaceFirst(
|
||||||
|
_iosContainerPattern,
|
||||||
|
_currentContainerPath!,
|
||||||
|
);
|
||||||
if (normalized != filePath) {
|
if (normalized != filePath) {
|
||||||
_log.d('Normalized iOS path: $filePath -> $normalized');
|
_log.d('Normalized iOS path: $filePath -> $normalized');
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Migrate iOS paths in database to use current container UUID
|
/// Migrate iOS paths in database to use current container UUID
|
||||||
/// This is called once after app update if container changed
|
/// This is called once after app update if container changed
|
||||||
Future<bool> migrateIosContainerPaths() async {
|
Future<bool> migrateIosContainerPaths() async {
|
||||||
if (!Platform.isIOS) return false;
|
if (!Platform.isIOS) return false;
|
||||||
|
|
||||||
await _initContainerPath();
|
await _initContainerPath();
|
||||||
if (_currentContainerPath == null) return false;
|
if (_currentContainerPath == null) return false;
|
||||||
|
|
||||||
final prefs = await _prefs;
|
final prefs = await _prefs;
|
||||||
final lastContainer = prefs.getString('ios_last_container_path');
|
final lastContainer = prefs.getString('ios_last_container_path');
|
||||||
|
|
||||||
if (lastContainer == _currentContainerPath) {
|
if (lastContainer == _currentContainerPath) {
|
||||||
_log.d('iOS container path unchanged, skipping migration');
|
_log.d('iOS container path unchanged, skipping migration');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('iOS container changed: $lastContainer -> $_currentContainerPath');
|
_log.i('iOS container changed: $lastContainer -> $_currentContainerPath');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|
||||||
// Get all items with iOS paths
|
// Get all items with iOS paths
|
||||||
final rows = await db.query('history', columns: ['id', 'file_path']);
|
final rows = await db.query('history', columns: ['id', 'file_path']);
|
||||||
int updatedCount = 0;
|
int updatedCount = 0;
|
||||||
final batch = db.batch();
|
final batch = db.batch();
|
||||||
|
|
||||||
for (final row in rows) {
|
for (final row in rows) {
|
||||||
final id = row['id'] as String;
|
final id = row['id'] as String;
|
||||||
final oldPath = row['file_path'] as String?;
|
final oldPath = row['file_path'] as String?;
|
||||||
|
|
||||||
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
|
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
|
||||||
final newPath = _normalizeIosPath(oldPath);
|
final newPath = _normalizeIosPath(oldPath);
|
||||||
if (newPath != oldPath) {
|
if (newPath != oldPath) {
|
||||||
|
|
@ -184,14 +191,14 @@ class HistoryDatabase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updatedCount > 0) {
|
if (updatedCount > 0) {
|
||||||
await batch.commit(noResult: true);
|
await batch.commit(noResult: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current container path
|
// Save current container path
|
||||||
await prefs.setString('ios_last_container_path', _currentContainerPath!);
|
await prefs.setString('ios_last_container_path', _currentContainerPath!);
|
||||||
|
|
||||||
_log.i('iOS path migration complete: $updatedCount paths updated');
|
_log.i('iOS path migration complete: $updatedCount paths updated');
|
||||||
return updatedCount > 0;
|
return updatedCount > 0;
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
|
|
@ -199,32 +206,34 @@ class HistoryDatabase {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Migrate data from SharedPreferences to SQLite
|
/// Migrate data from SharedPreferences to SQLite
|
||||||
/// Returns true if migration was performed, false if already migrated
|
/// Returns true if migration was performed, false if already migrated
|
||||||
Future<bool> migrateFromSharedPreferences() async {
|
Future<bool> migrateFromSharedPreferences() async {
|
||||||
final prefs = await _prefs;
|
final prefs = await _prefs;
|
||||||
final migrationKey = 'history_migrated_to_sqlite';
|
final migrationKey = 'history_migrated_to_sqlite';
|
||||||
|
|
||||||
if (prefs.getBool(migrationKey) == true) {
|
if (prefs.getBool(migrationKey) == true) {
|
||||||
_log.d('Already migrated to SQLite');
|
_log.d('Already migrated to SQLite');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final jsonStr = prefs.getString('download_history');
|
final jsonStr = prefs.getString('download_history');
|
||||||
if (jsonStr == null || jsonStr.isEmpty) {
|
if (jsonStr == null || jsonStr.isEmpty) {
|
||||||
_log.d('No SharedPreferences history to migrate');
|
_log.d('No SharedPreferences history to migrate');
|
||||||
await prefs.setBool(migrationKey, true);
|
await prefs.setBool(migrationKey, true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||||
_log.i('Migrating ${jsonList.length} items from SharedPreferences to SQLite');
|
_log.i(
|
||||||
|
'Migrating ${jsonList.length} items from SharedPreferences to SQLite',
|
||||||
|
);
|
||||||
|
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final batch = db.batch();
|
final batch = db.batch();
|
||||||
|
|
||||||
for (final json in jsonList) {
|
for (final json in jsonList) {
|
||||||
final map = json as Map<String, dynamic>;
|
final map = json as Map<String, dynamic>;
|
||||||
batch.insert(
|
batch.insert(
|
||||||
|
|
@ -233,20 +242,20 @@ class HistoryDatabase {
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await batch.commit(noResult: true);
|
await batch.commit(noResult: true);
|
||||||
|
|
||||||
// Mark as migrated but keep old data for safety
|
// Mark as migrated but keep old data for safety
|
||||||
await prefs.setBool(migrationKey, true);
|
await prefs.setBool(migrationKey, true);
|
||||||
_log.i('Migration complete: ${jsonList.length} items');
|
_log.i('Migration complete: ${jsonList.length} items');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_log.e('Migration failed: $e', e, stack);
|
_log.e('Migration failed: $e', e, stack);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert JSON format (camelCase) to DB row (snake_case)
|
/// Convert JSON format (camelCase) to DB row (snake_case)
|
||||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -278,7 +287,7 @@ class HistoryDatabase {
|
||||||
'copyright': json['copyright'],
|
'copyright': json['copyright'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert DB row (snake_case) to JSON format (camelCase)
|
/// Convert DB row (snake_case) to JSON format (camelCase)
|
||||||
/// Also normalizes iOS paths if container UUID changed
|
/// Also normalizes iOS paths if container UUID changed
|
||||||
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
|
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
|
||||||
|
|
@ -311,9 +320,9 @@ class HistoryDatabase {
|
||||||
'copyright': row['copyright'],
|
'copyright': row['copyright'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== CRUD Operations ====================
|
// ==================== 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;
|
||||||
|
|
@ -323,7 +332,7 @@ class HistoryDatabase {
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all history items ordered by download date (newest first)
|
/// Get all history items ordered by download date (newest first)
|
||||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|
@ -335,7 +344,7 @@ class HistoryDatabase {
|
||||||
);
|
);
|
||||||
return rows.map(_dbRowToJson).toList();
|
return rows.map(_dbRowToJson).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get item by ID
|
/// Get item by ID
|
||||||
Future<Map<String, dynamic>?> getById(String id) async {
|
Future<Map<String, dynamic>?> getById(String id) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|
@ -348,7 +357,7 @@ class HistoryDatabase {
|
||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
return _dbRowToJson(rows.first);
|
return _dbRowToJson(rows.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get item by Spotify ID - O(1) with index
|
/// Get item by Spotify ID - O(1) with index
|
||||||
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
|
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|
@ -361,7 +370,7 @@ class HistoryDatabase {
|
||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
return _dbRowToJson(rows.first);
|
return _dbRowToJson(rows.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get item by ISRC - O(1) with index
|
/// Get item by ISRC - O(1) with index
|
||||||
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
|
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|
@ -374,7 +383,7 @@ class HistoryDatabase {
|
||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
return _dbRowToJson(rows.first);
|
return _dbRowToJson(rows.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if spotify_id exists - O(1) with index
|
/// Check if spotify_id exists - O(1) with index
|
||||||
Future<bool> existsBySpotifyId(String spotifyId) async {
|
Future<bool> existsBySpotifyId(String spotifyId) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|
@ -384,42 +393,42 @@ class HistoryDatabase {
|
||||||
);
|
);
|
||||||
return result.isNotEmpty;
|
return result.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all spotify_ids as Set for fast in-memory lookup
|
/// Get all spotify_ids as Set for fast in-memory lookup
|
||||||
Future<Set<String>> getAllSpotifyIds() async {
|
Future<Set<String>> getAllSpotifyIds() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final rows = await db.rawQuery(
|
final rows = await db.rawQuery(
|
||||||
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""'
|
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""',
|
||||||
);
|
);
|
||||||
return rows.map((r) => r['spotify_id'] as String).toSet();
|
return rows.map((r) => r['spotify_id'] as String).toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete by ID
|
/// Delete by ID
|
||||||
Future<void> deleteById(String id) async {
|
Future<void> deleteById(String id) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete('history', where: 'id = ?', whereArgs: [id]);
|
await db.delete('history', where: 'id = ?', whereArgs: [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete by Spotify ID
|
/// Delete by Spotify ID
|
||||||
Future<void> deleteBySpotifyId(String spotifyId) async {
|
Future<void> deleteBySpotifyId(String spotifyId) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
|
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all history
|
/// Clear all history
|
||||||
Future<void> clearAll() async {
|
Future<void> clearAll() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete('history');
|
await db.delete('history');
|
||||||
_log.i('Cleared all history');
|
_log.i('Cleared all history');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get total count
|
/// Get total count
|
||||||
Future<int> getCount() async {
|
Future<int> getCount() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
|
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
|
||||||
return Sqflite.firstIntValue(result) ?? 0;
|
return Sqflite.firstIntValue(result) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find existing item by spotify_id or isrc (for deduplication)
|
/// Find existing item by spotify_id or isrc (for deduplication)
|
||||||
Future<Map<String, dynamic>?> findExisting({
|
Future<Map<String, dynamic>?> findExisting({
|
||||||
String? spotifyId,
|
String? spotifyId,
|
||||||
|
|
@ -428,7 +437,7 @@ class HistoryDatabase {
|
||||||
if (spotifyId != null && spotifyId.isNotEmpty) {
|
if (spotifyId != null && spotifyId.isNotEmpty) {
|
||||||
final bySpotify = await getBySpotifyId(spotifyId);
|
final bySpotify = await getBySpotifyId(spotifyId);
|
||||||
if (bySpotify != null) return bySpotify;
|
if (bySpotify != null) return bySpotify;
|
||||||
|
|
||||||
// Check for deezer: prefix matching
|
// Check for deezer: prefix matching
|
||||||
if (spotifyId.startsWith('deezer:')) {
|
if (spotifyId.startsWith('deezer:')) {
|
||||||
final deezerId = spotifyId.substring(7);
|
final deezerId = spotifyId.substring(7);
|
||||||
|
|
@ -442,31 +451,63 @@ class HistoryDatabase {
|
||||||
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
|
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isrc != null && isrc.isNotEmpty) {
|
if (isrc != null && isrc.isNotEmpty) {
|
||||||
return await getByIsrc(isrc);
|
return await getByIsrc(isrc);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close database
|
/// Close database
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.close();
|
await db.close();
|
||||||
_database = null;
|
_database = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update file path for a history entry (e.g. after format conversion)
|
||||||
|
Future<void> updateFilePath(
|
||||||
|
String id,
|
||||||
|
String newFilePath, {
|
||||||
|
String? newSafFileName,
|
||||||
|
String? newQuality,
|
||||||
|
int? newBitDepth,
|
||||||
|
int? newSampleRate,
|
||||||
|
bool clearAudioSpecs = false,
|
||||||
|
}) async {
|
||||||
|
final db = await database;
|
||||||
|
final values = <String, dynamic>{'file_path': newFilePath};
|
||||||
|
if (newSafFileName != null) {
|
||||||
|
values['saf_file_name'] = newSafFileName;
|
||||||
|
}
|
||||||
|
if (newQuality != null) {
|
||||||
|
values['quality'] = newQuality;
|
||||||
|
}
|
||||||
|
if (clearAudioSpecs) {
|
||||||
|
values['bit_depth'] = null;
|
||||||
|
values['sample_rate'] = null;
|
||||||
|
} else {
|
||||||
|
if (newBitDepth != null) {
|
||||||
|
values['bit_depth'] = newBitDepth;
|
||||||
|
}
|
||||||
|
if (newSampleRate != null) {
|
||||||
|
values['sample_rate'] = newSampleRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all file paths from download history
|
/// Get all file paths from download history
|
||||||
/// Used to exclude downloaded files from local library scan
|
/// Used to exclude downloaded files from local library scan
|
||||||
Future<Set<String>> getAllFilePaths() async {
|
Future<Set<String>> getAllFilePaths() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final rows = await db.rawQuery(
|
final rows = await db.rawQuery(
|
||||||
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""'
|
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""',
|
||||||
);
|
);
|
||||||
return rows.map((r) => r['file_path'] as String).toSet();
|
return rows.map((r) => r['file_path'] as String).toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all entries with file paths for orphan detection
|
/// Get all entries with file paths for orphan detection
|
||||||
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
|
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
|
||||||
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
|
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
|
||||||
|
|
@ -478,11 +519,11 @@ class HistoryDatabase {
|
||||||
''');
|
''');
|
||||||
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete multiple entries by IDs
|
/// Delete multiple entries by IDs
|
||||||
Future<int> deleteByIds(List<String> ids) async {
|
Future<int> deleteByIds(List<String> ids) async {
|
||||||
if (ids.isEmpty) return 0;
|
if (ids.isEmpty) return 0;
|
||||||
|
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final placeholders = List.filled(ids.length, '?').join(',');
|
final placeholders = List.filled(ids.length, '?').join(',');
|
||||||
final count = await db.rawDelete(
|
final count = await db.rawDelete(
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,7 @@ class BuiltInService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default quality options for built-in services (Tidal, Qobuz, YouTube)
|
/// Default quality options for built-in services (Tidal, Qobuz, Amazon, YouTube)
|
||||||
/// Note: Amazon is fallback-only and not shown in picker
|
|
||||||
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
|
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
|
||||||
const _builtInServices = [
|
const _builtInServices = [
|
||||||
BuiltInService(
|
BuiltInService(
|
||||||
|
|
@ -44,6 +43,17 @@ const _builtInServices = [
|
||||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
BuiltInService(
|
||||||
|
id: 'amazon',
|
||||||
|
label: 'Amazon',
|
||||||
|
qualityOptions: [
|
||||||
|
QualityOption(
|
||||||
|
id: 'LOSSLESS',
|
||||||
|
label: 'FLAC Best Available',
|
||||||
|
description: 'Amazon API delivers the best available lossless quality',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
BuiltInService(
|
BuiltInService(
|
||||||
id: 'youtube',
|
id: 'youtube',
|
||||||
label: 'YouTube',
|
label: 'YouTube',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.6.1+78
|
version: 3.6.5+79
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue