mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abc599d7f9 | |||
| 9b27e86e0f | |||
| dbe8f5d814 | |||
| 9847594ca1 | |||
| 986f5eafc8 | |||
| 84df64fcfe | |||
| a9150b85b9 | |||
| 68e6c8be35 | |||
| bd42655c0e | |||
| fe1c96ea12 | |||
| bae2bf63eb | |||
| b6574f0097 | |||
| c35a8dd803 | |||
| d54b2249b6 | |||
| f7be2c1e12 | |||
| ebe7d87da7 |
@@ -1,4 +1,3 @@
|
||||
github: zarzet
|
||||
ko_fi: zarzet
|
||||
buy_me_a_coffee: zarzet
|
||||
|
||||
|
||||
+138
@@ -1,5 +1,143 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
- "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`
|
||||
- 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
|
||||
- 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`
|
||||
- New Go unified router entrypoint: `DownloadByStrategy(requestJSON)`
|
||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||
- 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
|
||||
- Edit Metadata now supports manual cover selection (pick/replace cover image) and embeds it into audio tags on save
|
||||
- Save Lyrics now shows an immediate in-progress snackbar (`Saving lyrics...`) so users know the operation has started
|
||||
|
||||
### 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
|
||||
- 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`
|
||||
- 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)
|
||||
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||
- Edit Metadata cover section moved to the top of the form and now previews current embedded cover before replacement (plus selected replacement preview)
|
||||
- Edit Metadata cover preview enlarged (120px to 160px) with shadow, side-by-side layout for current vs selected cover, and label repositioned below image
|
||||
|
||||
### 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 `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
|
||||
- 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
|
||||
- `downloadWithExtensions` now carries `copyright`
|
||||
- YouTube path now carries `embed_max_quality_cover` and metadata parity fields
|
||||
- Inconsistent success response metadata between direct/fallback flows
|
||||
- Added shared Go response builder for `DownloadTrack` and `DownloadWithFallback`
|
||||
- Success responses now consistently include `genre`, `label`, `copyright`, and `lyrics_lrc`
|
||||
- YouTube success response now also includes extended metadata fields (`cover_url`, `genre`, `label`, `copyright`) for parity with other providers
|
||||
- Fixed `Save Lyrics` crash on Android (`java.lang.Integer cannot be cast to java.lang.Long`) by normalizing `duration_ms` channel argument as `Number -> Long`
|
||||
- Fixed FLAC Re-enrich cover edge case where metadata could be written without cover when temp cover file creation failed; FLAC cover embed now uses in-memory bytes and verifies cover after write
|
||||
- Fixed FLAC picture-block embed robustness by detecting image MIME via magic bytes (JPEG/PNG/GIF/WEBP) instead of relying on filename extension
|
||||
- Fixed MP3/Opus metadata rewrite flows to preserve existing embedded cover when no new cover is available
|
||||
- Fixed Library tab cover not updating after manual cover edit/re-embed for downloaded tracks
|
||||
- Queue/Library now prefers embedded cover art extracted from local files (not just cached `coverUrl`)
|
||||
- Added per-track extraction cache with file-modification invalidation so updated embedded art is reflected in Library
|
||||
- Extraction is now on-demand for edited tracks only (not full-library reload)
|
||||
- Returning from Track Metadata now refreshes cover cache only for the affected track
|
||||
- Cover refresh is now skipped when file modification time is unchanged, removing unnecessary flash when simply opening/closing metadata screen
|
||||
- Fixed repeated cover preview extraction in Track Metadata screen (`track_cover_preview_*`) causing visible flash when reopening
|
||||
- Added in-memory preview cache keyed by file path so reopening metadata reuses existing preview without re-extract
|
||||
- Cache validation uses file modification time for filesystem paths; SAF paths are refreshed only after successful edit actions
|
||||
- Queue/Library now also compares SAF file last-modified (`getSafFileModTimes`) before refreshing embedded-cover cache
|
||||
- Preview cache key is now stable per track item (not volatile temp SAF path), eliminating false cache misses on SAF-backed files
|
||||
- Track Metadata no longer auto-extracts cover preview on every screen open; extraction now runs only after actual edit/re-enrich changes (or when explicitly forced)
|
||||
- Track metadata edits/re-enrich now sync updated tags back into `downloadHistoryProvider` + SQLite history rows
|
||||
- Non-Library screens that read download history (Home/album/history views) now reflect updated title/artist/album/tags without manual rescan
|
||||
- Track Metadata back-navigation now returns an explicit update result after successful edits/re-enrich, enabling History-tab cover refresh fallback when SAF timestamps are unreliable
|
||||
|
||||
### Performance
|
||||
|
||||
- Configured Flutter image cache limits (240 entries / 60 MiB) and added `ResizeImage` wrappers for cover art precaching across all screens, reducing peak memory usage on cover-heavy pages
|
||||
- Added LRU eviction to Deezer cache with configurable max entries per cache type (search/album/artist/ISRC) and periodic expired-entry cleanup to prevent unbounded memory growth in long sessions
|
||||
- Download progress notifications are now normalized (2-decimal progress, 1-decimal speed, 0.1 MiB byte steps) and deduplicated by track/artist/percent/queue-count, reducing notification overhead during batch downloads
|
||||
- Each queue item now uses a dedicated `ConsumerWidget` with per-item `.select()` instead of rebuilding the entire list on any item change; items are wrapped in `RepaintBoundary` for paint isolation
|
||||
- Queue/Library search indexes are now built on-demand per item instead of upfront for all items, with bounded LRU caches (max 4000 entries)
|
||||
- `copyWith` now preserves derived lookup indexes (ISRC map, track key set) when items list is unchanged, avoiding O(n) rebuild on every scan progress update
|
||||
- Scan progress polling now compares values before calling `setState`, skipping unnecessary widget rebuilds when nothing changed
|
||||
- Added in-flight flag to download progress and library scan polling to prevent concurrent timer callbacks from overlapping
|
||||
- New `DownloadedEmbeddedCoverResolver` service replaces per-screen cover extraction logic with a shared bounded cache (160 entries), mod-time validation, and throttled refresh checks
|
||||
- Multiple embedded cover change callbacks are now coalesced into a single frame via `addPostFrameCallback`, preventing redundant rebuilds
|
||||
- Downloaded album screen now caches filtered/sorted track lists and reuses them when the source data reference is unchanged
|
||||
- Home tab recent downloads now use single-pass aggregation instead of building full per-album lists, and store only IDs instead of full item objects for the clear-all action
|
||||
- Removed duplicate `_downloadedSpotifyIds` Set and `_isrcSet` (both now use existing map lookups), removed unused `_isTyping` state in home tab
|
||||
- Track cache pre-warming is now capped at 80 tracks per request to avoid excessive backend calls on large playlists
|
||||
- About page contributor avatars now use `memCacheWidth`/`memCacheHeight` to decode at display size instead of full resolution
|
||||
- Orphaned download cleanup now checks file existence in parallel (chunk 16) instead of sequentially
|
||||
- Local library `findByTrackAndArtist` now uses O(1) map lookup (`_byTrackKey`) instead of O(n) linear scan
|
||||
- Local library database load and SharedPreferences fetch now run in parallel
|
||||
- Legacy mod-time backfill now uses chunked parallel `File.stat` (chunk 24) with per-chunk cancel check
|
||||
- Downloaded album screen now caches disc grouping, sorted disc numbers, common quality, and embedded cover path with reference-identity invalidation
|
||||
- Local album screen common quality is now computed once during cache rebuild instead of per-build
|
||||
- Batch delete in album screens now uses O(1) map lookup (`tracksById`) instead of `.where().firstOrNull`
|
||||
- Cache management page now fires all async init calls in parallel and uses chunked async directory deletion (chunk 24)
|
||||
- Cover resolver preview file existence check is now throttled (2.2s interval) to reduce synchronous I/O in build path
|
||||
- History and library database DELETE operations are now chunked (500 per batch) to stay within SQLite variable limits
|
||||
- Library database `cleanupMissingFiles` now checks file existence in parallel (chunk 16) and deletes in batched SQL
|
||||
|
||||
### Security
|
||||
|
||||
- All logs (Go and Dart) now automatically redact Bearer tokens, access/refresh tokens, client secrets, API keys, and passwords using regex-based sanitization before storage
|
||||
- Extension auth URLs are now validated for HTTPS-only, no embedded credentials, and no private/local network targets before opening
|
||||
- Auth URLs in logs are summarized to scheme+host+path only (query params stripped) to prevent token leakage; token exchange error bodies are truncated and sanitized
|
||||
- Extension HTTP requests now block URLs with embedded credentials (`user:pass@host`)
|
||||
- Extension storage files changed from `0644` to `0600` (owner-only read/write)
|
||||
- All SAF relative directory paths are now sanitized per-segment with `.`/`..` filtering; all user-provided file names pass through `sanitizeFilename()` before use
|
||||
- Extension ID is sanitized before building download destination path
|
||||
- Log export device info now shows Build ID and Security Patch level instead of masked Device ID
|
||||
|
||||
### Technical
|
||||
|
||||
- Centralized request serialization in `PlatformBridge` via shared invoke helper and unified payload model
|
||||
- Go strategy router normalizes incoming service casing before dispatch
|
||||
- Extension runtime: `customSearch` now passes query/options via VM globals instead of string interpolation, preventing parser edge cases on certain devices
|
||||
- Extension runtime: JS panic handler now logs full stack trace for easier debugging
|
||||
- `DownloadQueueLookup` expanded with `byItemId` map and `itemIds` list for O(1) queue item access from UI
|
||||
- Non-error/non-fatal log entries are now skipped entirely (not just hidden) when detailed logging is disabled, reducing buffer growth and Go log polling overhead
|
||||
|
||||
### Removed
|
||||
|
||||
- Buy Me a Coffee references removed from donate page, FUNDING.yml, README, and all localization files (account suspended)
|
||||
|
||||
---
|
||||
|
||||
## [3.6.0] - 2026-02-09
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/dec9c96672ab80e6bf6b7a66786e612f5404446c341eb0311b4cc78fe10c96a1)
|
||||
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
<div align="center">
|
||||
@@ -71,9 +71,9 @@ A: Some countries have restricted access to certain streaming service APIs. If d
|
||||
|
||||
### Want to support SpotiFLAC-Mobile?
|
||||
|
||||
_If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going._
|
||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
||||
|
||||
[](https://ko-fi.com/zarzet) <a href="https://www.buymeacoffee.com/zarzet" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 40px !important;width: 150px !important;" ></a>
|
||||
[](https://ko-fi.com/zarzet)
|
||||
|
||||
|
||||
## Disclaimer
|
||||
|
||||
@@ -33,6 +33,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||
private val safScanLock = Any()
|
||||
private val safDirLock = Any()
|
||||
private var safScanProgress = SafScanProgress()
|
||||
@Volatile private var safScanCancel = false
|
||||
@Volatile private var safScanActive = false
|
||||
@@ -299,27 +300,55 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
|
||||
}
|
||||
|
||||
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
||||
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
||||
if (relativeDir.isBlank()) return current
|
||||
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
if (relativeDir.isBlank()) return ""
|
||||
return relativeDir
|
||||
.split("/")
|
||||
.map { sanitizeFilename(it) }
|
||||
.filter { it.isNotBlank() && it != "." && it != ".." }
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
val parts = relativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
current = if (existing != null && existing.isDirectory) {
|
||||
existing
|
||||
} else {
|
||||
current.createDirectory(part) ?: return null
|
||||
}
|
||||
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
if (safeRelativeDir.isBlank()) {
|
||||
return DocumentFile.fromTreeUri(this, treeUri)
|
||||
}
|
||||
|
||||
// Synchronize to prevent concurrent downloads from creating duplicate
|
||||
// directories with (1), (2) suffixes via SAF's auto-rename behavior.
|
||||
synchronized(safDirLock) {
|
||||
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
||||
|
||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
current = if (existing != null && existing.isDirectory) {
|
||||
existing
|
||||
} else {
|
||||
val created = current.createDirectory(part) ?: return null
|
||||
// SAF may auto-rename to "part (1)" if another thread just created it.
|
||||
// Re-check: if the created name differs, delete it and use the original.
|
||||
val createdName = created.name ?: part
|
||||
if (createdName != part) {
|
||||
// Another thread won the race; delete the duplicate and use theirs.
|
||||
created.delete()
|
||||
current.findFile(part) ?: return null
|
||||
} else {
|
||||
created
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
||||
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
||||
if (relativeDir.isBlank()) return current
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
if (safeRelativeDir.isBlank()) return current
|
||||
|
||||
val parts = relativeDir.split("/").filter { it.isNotBlank() }
|
||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
if (existing == null || !existing.isDirectory) return null
|
||||
@@ -359,14 +388,21 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
obj.put("relative_dir", "")
|
||||
return obj.toString()
|
||||
}
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val safeFileName = sanitizeFilename(fileName)
|
||||
if (safeFileName.isBlank()) {
|
||||
obj.put("uri", "")
|
||||
obj.put("relative_dir", "")
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = findDocumentDir(treeUri, relativeDir)
|
||||
val targetDir = findDocumentDir(treeUri, safeRelativeDir)
|
||||
if (targetDir != null) {
|
||||
val direct = targetDir.findFile(fileName)
|
||||
val direct = targetDir.findFile(safeFileName)
|
||||
if (direct != null && direct.isFile) {
|
||||
obj.put("uri", direct.uri.toString())
|
||||
obj.put("relative_dir", relativeDir)
|
||||
obj.put("relative_dir", safeRelativeDir)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
@@ -392,7 +428,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val childPath = if (path.isBlank()) childName else "$path/$childName"
|
||||
queue.add(child to childPath)
|
||||
} else if (child.isFile) {
|
||||
if (child.name == fileName) {
|
||||
if (child.name == safeFileName) {
|
||||
obj.put("uri", child.uri.toString())
|
||||
obj.put("relative_dir", path)
|
||||
return obj.toString()
|
||||
@@ -408,7 +444,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||
val provided = req.optString("saf_file_name", "")
|
||||
if (provided.isNotBlank()) return provided
|
||||
if (provided.isNotBlank()) return sanitizeFilename(provided)
|
||||
|
||||
val trackName = req.optString("track_name", "track")
|
||||
val artistName = req.optString("artist_name", "")
|
||||
@@ -599,7 +635,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val relativeDir = req.optString("saf_relative_dir", "")
|
||||
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
@@ -1276,20 +1312,11 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"downloadTrack" -> {
|
||||
"downloadByStrategy" -> {
|
||||
val requestJson = call.arguments as String
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
handleSafDownload(requestJson) { json ->
|
||||
Gobackend.downloadTrack(json)
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"downloadWithFallback" -> {
|
||||
val requestJson = call.arguments as String
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
handleSafDownload(requestJson) { json ->
|
||||
Gobackend.downloadWithFallback(json)
|
||||
Gobackend.downloadByStrategy(json)
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
@@ -1465,11 +1492,12 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"safCreateFromPath" -> {
|
||||
val treeUriStr = call.argument<String>("tree_uri") ?: ""
|
||||
val relativeDir = call.argument<String>("relative_dir") ?: ""
|
||||
val fileName = call.argument<String>("file_name") ?: ""
|
||||
val fileName = sanitizeFilename(call.argument<String>("file_name") ?: "")
|
||||
val mimeType = call.argument<String>("mime_type") ?: "application/octet-stream"
|
||||
val srcPath = call.argument<String>("src_path") ?: ""
|
||||
val createdUri = withContext(Dispatchers.IO) {
|
||||
if (treeUriStr.isBlank()) return@withContext null
|
||||
if (fileName.isBlank()) return@withContext null
|
||||
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
|
||||
val existing = dir.findFile(fileName)
|
||||
val createdNew = existing == null
|
||||
@@ -1716,7 +1744,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val trackName = call.argument<String>("track_name") ?: ""
|
||||
val artistName = call.argument<String>("artist_name") ?: ""
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
val durationMs = call.argument<Long>("duration_ms") ?: 0L
|
||||
val durationMs = call.argument<Number>("duration_ms")?.toLong() ?: 0L
|
||||
val outputPath = call.argument<String>("output_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -2093,24 +2121,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"downloadWithExtensions" -> {
|
||||
val requestJson = call.arguments as String
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
handleSafDownload(requestJson) { json ->
|
||||
Gobackend.downloadWithExtensionsJSON(json)
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"downloadFromYouTube" -> {
|
||||
val requestJson = call.arguments as String
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
handleSafDownload(requestJson) { json ->
|
||||
Gobackend.downloadFromYouTube(json)
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"enrichTrackWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val trackJson = call.argument<String>("track") ?: "{}"
|
||||
|
||||
+231
-81
@@ -31,6 +31,8 @@ type AmazonDownloader struct {
|
||||
var (
|
||||
globalAmazonDownloader *AmazonDownloader
|
||||
amazonDownloaderOnce sync.Once
|
||||
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
|
||||
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
|
||||
)
|
||||
|
||||
// AfkarXYZResponse is the response from AfkarXYZ API
|
||||
@@ -43,6 +45,12 @@ type AfkarXYZResponse struct {
|
||||
} `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 {
|
||||
amazonDownloaderOnce.Do(func() {
|
||||
globalAmazonDownloader = &AmazonDownloader{
|
||||
@@ -52,10 +60,9 @@ func NewAmazonDownloader() *AmazonDownloader {
|
||||
return globalAmazonDownloader
|
||||
}
|
||||
|
||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks
|
||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) {
|
||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||
|
||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
|
||||
// Returns downloadURL, suggested fileName, optional decryptionKey.
|
||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -64,66 +71,184 @@ func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, st
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL)
|
||||
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
|
||||
if err == nil {
|
||||
return downloadURL, fileName, nil
|
||||
return downloadURL, fileName, decryptionKey, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
errStr := err.Error()
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Check if error is retryable
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "eof") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429")
|
||||
strings.Contains(errStr, "status 429") ||
|
||||
strings.Contains(errStr, "http 429")
|
||||
|
||||
if !isRetryable {
|
||||
return "", "", err
|
||||
return "", "", "", 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 (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) {
|
||||
func normalizeAmazonASIN(candidate string) string {
|
||||
trimmed := strings.TrimSpace(candidate)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if decoded, err := url.QueryUnescape(trimmed); err == nil {
|
||||
trimmed = decoded
|
||||
}
|
||||
|
||||
trimmed = strings.ToUpper(trimmed)
|
||||
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
|
||||
if amazonASINRegex.MatchString(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractAmazonASIN(amazonURL string) string {
|
||||
raw := strings.TrimSpace(amazonURL)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err == nil {
|
||||
query := parsed.Query()
|
||||
|
||||
// Prefer track-level ASIN when URL also contains albumAsin.
|
||||
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
|
||||
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
|
||||
return asin
|
||||
}
|
||||
}
|
||||
|
||||
path := strings.Trim(parsed.Path, "/")
|
||||
if path != "" {
|
||||
segments := strings.Split(path, "/")
|
||||
|
||||
for i := 0; i < len(segments)-1; i++ {
|
||||
segment := strings.ToLower(strings.TrimSpace(segments[i]))
|
||||
if segment == "track" || segment == "tracks" {
|
||||
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
|
||||
return asin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
|
||||
return asin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
|
||||
return normalizeAmazonASIN(match)
|
||||
}
|
||||
|
||||
// doAfkarXYZRequest performs a single request to Amazon API.
|
||||
// It tries new endpoint first, then falls back to legacy /convert endpoint.
|
||||
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
|
||||
asin := extractAmazonASIN(amazonURL)
|
||||
if asin != "" {
|
||||
GoLog("[Amazon] Using ASIN: %s\n", asin)
|
||||
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
|
||||
if err == nil {
|
||||
return downloadURL, fileName, decryptKey, nil
|
||||
}
|
||||
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
|
||||
}
|
||||
return a.doAfkarXYZRequestLegacy(amazonURL)
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||
defer cancel()
|
||||
|
||||
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
||||
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())
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
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()
|
||||
|
||||
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)
|
||||
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
|
||||
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 == "" {
|
||||
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
|
||||
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
|
||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
|
||||
}
|
||||
|
||||
fileName := apiResp.Data.FileName
|
||||
@@ -134,19 +259,22 @@ func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, err
|
||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||
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")
|
||||
|
||||
downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL)
|
||||
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
|
||||
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)
|
||||
return downloadURL, fileName, nil
|
||||
return downloadURL, fileName, decryptionKey, nil
|
||||
}
|
||||
|
||||
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
|
||||
type AmazonDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
DecryptionKey string
|
||||
}
|
||||
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
@@ -299,7 +428,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}
|
||||
|
||||
// Download using AfkarXYZ API
|
||||
downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
||||
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
} 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)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
@@ -352,6 +485,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
actualOutputPath := outputPath
|
||||
needsDecryption := strings.TrimSpace(decryptionKey) != ""
|
||||
if needsDecryption {
|
||||
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
|
||||
}
|
||||
|
||||
// Wait for parallel operations to complete
|
||||
<-parallelDone
|
||||
|
||||
@@ -360,7 +499,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||
actualTrackNum := req.TrackNumber
|
||||
actualDiscNum := req.DiscNumber
|
||||
actualDate := req.ReleaseDate
|
||||
@@ -368,25 +506,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
actualTitle := req.TrackName
|
||||
actualArtist := req.ArtistName
|
||||
|
||||
if metaErr == nil && existingMeta != nil {
|
||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||
actualTrackNum = existingMeta.TrackNumber
|
||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||
if !needsDecryption {
|
||||
existingMeta, metaErr := ReadMetadata(actualOutputPath)
|
||||
if metaErr == nil && existingMeta != nil {
|
||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||
actualTrackNum = existingMeta.TrackNumber
|
||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||
}
|
||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||
actualDiscNum = existingMeta.DiscNumber
|
||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||
}
|
||||
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
||||
actualDate = existingMeta.Date
|
||||
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
||||
}
|
||||
if existingMeta.Album != "" && req.AlbumName == "" {
|
||||
actualAlbum = existingMeta.Album
|
||||
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
||||
}
|
||||
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
||||
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
||||
}
|
||||
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{
|
||||
@@ -409,7 +550,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
coverData = parallelResult.CoverData
|
||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
} else {
|
||||
existingCover, coverErr := ExtractCoverArt(outputPath)
|
||||
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
|
||||
if coverErr == nil && len(existingCover) > 0 {
|
||||
coverData = existingCover
|
||||
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
||||
@@ -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")
|
||||
} else {
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
|
||||
if isFlacOutput {
|
||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
@@ -433,20 +579,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
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)
|
||||
} else {
|
||||
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))
|
||||
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)
|
||||
} else {
|
||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||
}
|
||||
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
|
||||
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||
@@ -456,17 +604,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||
|
||||
quality := AudioQuality{}
|
||||
if isSafOutput {
|
||||
if isSafOutput || needsDecryption {
|
||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
||||
} else {
|
||||
quality, err = GetAudioQuality(outputPath)
|
||||
quality, err = GetAudioQuality(actualOutputPath)
|
||||
if err != nil {
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
} else {
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
|
||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
|
||||
if metaReadErr == nil && finalMeta != nil {
|
||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||
@@ -478,9 +626,10 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add to ISRC index for fast duplicate checking
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
// Add to ISRC index for fast duplicate checking.
|
||||
// When decryption is pending in Flutter, postpone indexing until final file is settled.
|
||||
if !isSafOutput && !needsDecryption {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
}
|
||||
|
||||
bitDepth := 0
|
||||
@@ -496,16 +645,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}
|
||||
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: actualTrackNum,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: actualTrackNum,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
DecryptionKey: decryptionKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
Lyrics string
|
||||
Label string
|
||||
Copyright string
|
||||
Composer string
|
||||
@@ -181,6 +182,15 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
||||
metadata.Label = value
|
||||
case "TCR":
|
||||
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
|
||||
@@ -297,6 +307,15 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
if v := extractCommentFrame(frameData); 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
|
||||
@@ -399,6 +418,98 @@ func extractCommentFrame(data []byte) string {
|
||||
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 {
|
||||
if len(data) < 2 {
|
||||
return ""
|
||||
@@ -800,9 +911,16 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
break
|
||||
}
|
||||
|
||||
if commentLen > 10000 {
|
||||
remaining := uint32(reader.Len())
|
||||
if commentLen > remaining {
|
||||
break
|
||||
}
|
||||
// Large comment entries are typically METADATA_BLOCK_PICTURE.
|
||||
// Skip them so we can continue parsing normal text tags after/before.
|
||||
if commentLen > 512*1024 {
|
||||
reader.Seek(int64(commentLen), io.SeekCurrent)
|
||||
continue
|
||||
}
|
||||
|
||||
comment := make([]byte, commentLen)
|
||||
if _, err := reader.Read(comment); err != nil {
|
||||
@@ -843,6 +961,10 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
metadata.Composer = value
|
||||
case "COMMENT", "DESCRIPTION":
|
||||
metadata.Comment = value
|
||||
case "LYRICS", "UNSYNCEDLYRICS":
|
||||
if metadata.Lyrics == "" {
|
||||
metadata.Lyrics = value
|
||||
}
|
||||
case "ORGANIZATION", "LABEL", "PUBLISHER":
|
||||
metadata.Label = value
|
||||
case "COPYRIGHT":
|
||||
|
||||
+129
-15
@@ -28,15 +28,23 @@ const (
|
||||
deezerAPITimeoutMobile = 25 * time.Second
|
||||
deezerMaxRetries = 2
|
||||
deezerRetryDelay = 500 * time.Millisecond
|
||||
|
||||
deezerMaxSearchCacheEntries = 300
|
||||
deezerMaxAlbumCacheEntries = 200
|
||||
deezerMaxArtistCacheEntries = 200
|
||||
deezerMaxISRCCacheEntries = 4000
|
||||
deezerCacheCleanupInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
type DeezerClient struct {
|
||||
httpClient *http.Client
|
||||
searchCache map[string]*cacheEntry
|
||||
albumCache map[string]*cacheEntry
|
||||
artistCache map[string]*cacheEntry
|
||||
isrcCache map[string]string
|
||||
cacheMu sync.RWMutex
|
||||
httpClient *http.Client
|
||||
searchCache map[string]*cacheEntry
|
||||
albumCache map[string]*cacheEntry
|
||||
artistCache map[string]*cacheEntry
|
||||
isrcCache map[string]string
|
||||
cacheMu sync.RWMutex
|
||||
lastCacheCleanup time.Time
|
||||
cacheCleanupInterval time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -47,16 +55,111 @@ var (
|
||||
func GetDeezerClient() *DeezerClient {
|
||||
deezerClientOnce.Do(func() {
|
||||
deezerClient = &DeezerClient{
|
||||
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
|
||||
searchCache: make(map[string]*cacheEntry),
|
||||
albumCache: make(map[string]*cacheEntry),
|
||||
artistCache: make(map[string]*cacheEntry),
|
||||
isrcCache: make(map[string]string),
|
||||
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
|
||||
searchCache: make(map[string]*cacheEntry),
|
||||
albumCache: make(map[string]*cacheEntry),
|
||||
artistCache: make(map[string]*cacheEntry),
|
||||
isrcCache: make(map[string]string),
|
||||
cacheCleanupInterval: deezerCacheCleanupInterval,
|
||||
}
|
||||
})
|
||||
return deezerClient
|
||||
}
|
||||
|
||||
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
|
||||
cache map[string]*cacheEntry,
|
||||
now time.Time,
|
||||
) {
|
||||
for key, entry := range cache {
|
||||
if entry == nil || now.After(entry.expiresAt) {
|
||||
delete(cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DeezerClient) trimCacheEntriesLocked(
|
||||
cache map[string]*cacheEntry,
|
||||
maxEntries int,
|
||||
) {
|
||||
if maxEntries <= 0 || len(cache) <= maxEntries {
|
||||
return
|
||||
}
|
||||
|
||||
for len(cache) > maxEntries {
|
||||
var oldestKey string
|
||||
var oldestExpiry time.Time
|
||||
first := true
|
||||
for key, entry := range cache {
|
||||
expiry := time.Time{}
|
||||
if entry != nil {
|
||||
expiry = entry.expiresAt
|
||||
}
|
||||
if first || expiry.Before(oldestExpiry) {
|
||||
first = false
|
||||
oldestKey = key
|
||||
oldestExpiry = expiry
|
||||
}
|
||||
}
|
||||
if oldestKey == "" {
|
||||
return
|
||||
}
|
||||
delete(cache, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DeezerClient) trimStringCacheEntriesLocked(
|
||||
cache map[string]string,
|
||||
maxEntries int,
|
||||
) {
|
||||
if maxEntries <= 0 || len(cache) <= maxEntries {
|
||||
return
|
||||
}
|
||||
|
||||
toRemove := len(cache) - maxEntries
|
||||
for key := range cache {
|
||||
delete(cache, key)
|
||||
toRemove--
|
||||
if toRemove <= 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
|
||||
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
|
||||
(c.lastCacheCleanup.IsZero() ||
|
||||
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
|
||||
|
||||
if periodicCleanupDue {
|
||||
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
||||
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
||||
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
||||
c.lastCacheCleanup = now
|
||||
}
|
||||
|
||||
if len(c.searchCache) > deezerMaxSearchCacheEntries {
|
||||
if !periodicCleanupDue {
|
||||
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
||||
}
|
||||
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
|
||||
}
|
||||
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
|
||||
if !periodicCleanupDue {
|
||||
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
||||
}
|
||||
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
|
||||
}
|
||||
if len(c.artistCache) > deezerMaxArtistCacheEntries {
|
||||
if !periodicCleanupDue {
|
||||
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
||||
}
|
||||
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
|
||||
}
|
||||
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
|
||||
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
|
||||
}
|
||||
}
|
||||
|
||||
type deezerTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -414,10 +517,12 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
|
||||
|
||||
c.cacheMu.Lock()
|
||||
now := time.Now()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||
expiresAt: now.Add(deezerCacheTTL),
|
||||
}
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
@@ -555,10 +660,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
now := time.Now()
|
||||
c.albumCache[albumID] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||
expiresAt: now.Add(deezerCacheTTL),
|
||||
}
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
@@ -638,10 +745,12 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
now := time.Now()
|
||||
c.artistCache[artistID] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||
expiresAt: now.Add(deezerCacheTTL),
|
||||
}
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
@@ -807,6 +916,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
for trackIDStr, isrc := range directISRCs {
|
||||
c.isrcCache[trackIDStr] = isrc
|
||||
}
|
||||
c.maybeCleanupCachesLocked(time.Now())
|
||||
c.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -841,6 +951,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
||||
c.maybeCleanupCachesLocked(time.Now())
|
||||
c.cacheMu.Unlock()
|
||||
}(track)
|
||||
}
|
||||
@@ -864,6 +975,7 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.isrcCache[trackID] = fullTrack.ISRC
|
||||
c.maybeCleanupCachesLocked(time.Now())
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return fullTrack.ISRC, nil
|
||||
@@ -946,10 +1058,12 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
now := time.Now()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||
expiresAt: now.Add(deezerCacheTTL),
|
||||
}
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||
|
||||
+337
-118
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -47,10 +48,30 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||
|
||||
client, err := NewSpotifyMetadataClient()
|
||||
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
|
||||
}
|
||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -150,6 +171,8 @@ type DownloadRequest struct {
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
UseExtensions bool `json:"use_extensions,omitempty"`
|
||||
UseFallback bool `json:"use_fallback,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResponse struct {
|
||||
@@ -176,20 +199,90 @@ type DownloadResponse struct {
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
DecryptionKey string
|
||||
}
|
||||
|
||||
func buildDownloadSuccessResponse(
|
||||
req DownloadRequest,
|
||||
result DownloadResult,
|
||||
service string,
|
||||
message string,
|
||||
filePath string,
|
||||
alreadyExists bool,
|
||||
) DownloadResponse {
|
||||
title := result.Title
|
||||
if title == "" {
|
||||
title = req.TrackName
|
||||
}
|
||||
|
||||
artist := result.Artist
|
||||
if artist == "" {
|
||||
artist = req.ArtistName
|
||||
}
|
||||
|
||||
album := result.Album
|
||||
if album == "" {
|
||||
album = req.AlbumName
|
||||
}
|
||||
|
||||
releaseDate := result.ReleaseDate
|
||||
if releaseDate == "" {
|
||||
releaseDate = req.ReleaseDate
|
||||
}
|
||||
|
||||
trackNumber := result.TrackNumber
|
||||
if trackNumber == 0 {
|
||||
trackNumber = req.TrackNumber
|
||||
}
|
||||
|
||||
discNumber := result.DiscNumber
|
||||
if discNumber == 0 {
|
||||
discNumber = req.DiscNumber
|
||||
}
|
||||
|
||||
isrc := result.ISRC
|
||||
if isrc == "" {
|
||||
isrc = req.ISRC
|
||||
}
|
||||
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
FilePath: filePath,
|
||||
AlreadyExists: alreadyExists,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: service,
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ReleaseDate: releaseDate,
|
||||
TrackNumber: trackNumber,
|
||||
DiscNumber: discNumber,
|
||||
ISRC: isrc,
|
||||
CoverURL: req.CoverURL,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
}
|
||||
}
|
||||
|
||||
func DownloadTrack(requestJSON string) (string, error) {
|
||||
@@ -254,17 +347,18 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||
if amazonErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: amazonResult.FilePath,
|
||||
BitDepth: amazonResult.BitDepth,
|
||||
SampleRate: amazonResult.SampleRate,
|
||||
Title: amazonResult.Title,
|
||||
Artist: amazonResult.Artist,
|
||||
Album: amazonResult.Album,
|
||||
ReleaseDate: amazonResult.ReleaseDate,
|
||||
TrackNumber: amazonResult.TrackNumber,
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
LyricsLRC: amazonResult.LyricsLRC,
|
||||
FilePath: amazonResult.FilePath,
|
||||
BitDepth: amazonResult.BitDepth,
|
||||
SampleRate: amazonResult.SampleRate,
|
||||
Title: amazonResult.Title,
|
||||
Artist: amazonResult.Artist,
|
||||
Album: amazonResult.Album,
|
||||
ReleaseDate: amazonResult.ReleaseDate,
|
||||
TrackNumber: amazonResult.TrackNumber,
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
LyricsLRC: amazonResult.LyricsLRC,
|
||||
DecryptionKey: amazonResult.DecryptionKey,
|
||||
}
|
||||
}
|
||||
err = amazonErr
|
||||
@@ -301,22 +395,14 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
FilePath: actualPath,
|
||||
AlreadyExists: true,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: req.Service,
|
||||
Title: result.Title,
|
||||
Artist: result.Artist,
|
||||
Album: result.Album,
|
||||
ReleaseDate: result.ReleaseDate,
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
}
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
req.Service,
|
||||
"File already exists",
|
||||
actualPath,
|
||||
true,
|
||||
)
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
@@ -330,27 +416,60 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||
}
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "Download complete",
|
||||
FilePath: result.FilePath,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: req.Service,
|
||||
Title: result.Title,
|
||||
Artist: result.Artist,
|
||||
Album: result.Album,
|
||||
ReleaseDate: result.ReleaseDate,
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
}
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
req.Service,
|
||||
"Download complete",
|
||||
result.FilePath,
|
||||
false,
|
||||
)
|
||||
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// DownloadByStrategy routes a unified download request to the appropriate flow.
|
||||
// Routing priority: YouTube service > extension fallback > built-in fallback > direct service.
|
||||
func DownloadByStrategy(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
|
||||
serviceRaw := strings.TrimSpace(req.Service)
|
||||
serviceNormalized := strings.ToLower(serviceRaw)
|
||||
|
||||
normalizedReq := req
|
||||
if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) {
|
||||
normalizedReq.Service = serviceNormalized
|
||||
}
|
||||
|
||||
normalizedBytes, err := json.Marshal(normalizedReq)
|
||||
if err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
normalizedJSON := string(normalizedBytes)
|
||||
|
||||
if serviceNormalized == "youtube" {
|
||||
return DownloadFromYouTube(normalizedJSON)
|
||||
}
|
||||
|
||||
if req.UseExtensions {
|
||||
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
|
||||
if err != nil {
|
||||
return errorResponse(err.Error())
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if req.UseFallback {
|
||||
return DownloadWithFallback(normalizedJSON)
|
||||
}
|
||||
|
||||
return DownloadTrack(normalizedJSON)
|
||||
}
|
||||
|
||||
func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
@@ -440,17 +559,18 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||
if amazonErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: amazonResult.FilePath,
|
||||
BitDepth: amazonResult.BitDepth,
|
||||
SampleRate: amazonResult.SampleRate,
|
||||
Title: amazonResult.Title,
|
||||
Artist: amazonResult.Artist,
|
||||
Album: amazonResult.Album,
|
||||
ReleaseDate: amazonResult.ReleaseDate,
|
||||
TrackNumber: amazonResult.TrackNumber,
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
LyricsLRC: amazonResult.LyricsLRC,
|
||||
FilePath: amazonResult.FilePath,
|
||||
BitDepth: amazonResult.BitDepth,
|
||||
SampleRate: amazonResult.SampleRate,
|
||||
Title: amazonResult.Title,
|
||||
Artist: amazonResult.Artist,
|
||||
Album: amazonResult.Album,
|
||||
ReleaseDate: amazonResult.ReleaseDate,
|
||||
TrackNumber: amazonResult.TrackNumber,
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
LyricsLRC: amazonResult.LyricsLRC,
|
||||
DecryptionKey: amazonResult.DecryptionKey,
|
||||
}
|
||||
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||
@@ -470,23 +590,14 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
FilePath: actualPath,
|
||||
AlreadyExists: true,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: service,
|
||||
Title: result.Title,
|
||||
Artist: result.Artist,
|
||||
Album: result.Album,
|
||||
ReleaseDate: result.ReleaseDate,
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
}
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
service,
|
||||
"File already exists",
|
||||
actualPath,
|
||||
true,
|
||||
)
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
@@ -500,22 +611,14 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||
}
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "Downloaded from " + service,
|
||||
FilePath: result.FilePath,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: service,
|
||||
Title: result.Title,
|
||||
Artist: result.Artist,
|
||||
Album: result.Album,
|
||||
ReleaseDate: result.ReleaseDate,
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
}
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
service,
|
||||
"Downloaded from "+service,
|
||||
result.FilePath,
|
||||
false,
|
||||
)
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
@@ -622,6 +725,7 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["track_number"] = meta.TrackNumber
|
||||
result["disc_number"] = meta.DiscNumber
|
||||
result["isrc"] = meta.ISRC
|
||||
result["lyrics"] = meta.Lyrics
|
||||
result["genre"] = meta.Genre
|
||||
result["composer"] = meta.Composer
|
||||
result["comment"] = meta.Comment
|
||||
@@ -646,6 +750,7 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["track_number"] = meta.TrackNumber
|
||||
result["disc_number"] = meta.DiscNumber
|
||||
result["isrc"] = meta.ISRC
|
||||
result["lyrics"] = meta.Lyrics
|
||||
result["genre"] = meta.Genre
|
||||
result["composer"] = meta.Composer
|
||||
result["comment"] = meta.Comment
|
||||
@@ -678,6 +783,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
|
||||
lower := strings.ToLower(filePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||
|
||||
if isFlac {
|
||||
trackNum := 0
|
||||
@@ -705,7 +811,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
Comment: fields["comment"],
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(filePath, meta, ""); err != nil {
|
||||
if err := EmbedMetadata(filePath, meta, coverPath); err != nil {
|
||||
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -1101,9 +1207,12 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var spotifyErr error
|
||||
|
||||
client, err := NewSpotifyMetadataClient()
|
||||
if err != nil {
|
||||
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
||||
spotifyErr = err
|
||||
} else {
|
||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||
if err == nil {
|
||||
@@ -1114,28 +1223,81 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||
spotifyErr = err
|
||||
if !shouldTrySpotFetchFallback(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)
|
||||
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" {
|
||||
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1266,6 +1428,10 @@ func DownloadFromYouTube(requestJSON string) (string, error) {
|
||||
DiscNumber: youtubeResult.DiscNumber,
|
||||
ISRC: youtubeResult.ISRC,
|
||||
LyricsLRC: youtubeResult.LyricsLRC,
|
||||
CoverURL: req.CoverURL,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
@@ -1528,19 +1694,47 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
|
||||
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
|
||||
|
||||
lower := strings.ToLower(req.FilePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
|
||||
// Download cover art to temp file
|
||||
var coverTempPath string
|
||||
var coverDataBytes []byte
|
||||
if req.CoverURL != "" {
|
||||
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
|
||||
} else {
|
||||
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
|
||||
if err == nil {
|
||||
coverTempPath = tmpFile.Name()
|
||||
tmpFile.Write(coverData)
|
||||
tmpFile.Close()
|
||||
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
|
||||
coverDataBytes = coverData
|
||||
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
|
||||
// MP3/Opus requires a real image file path for Dart FFmpeg.
|
||||
// FLAC uses in-memory embed and does not require temp files.
|
||||
if !isFlac {
|
||||
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
|
||||
if err != nil {
|
||||
fallbackDir := filepath.Dir(req.FilePath)
|
||||
if fallbackDir == "" || fallbackDir == "." {
|
||||
GoLog("[ReEnrich] Failed to create cover temp file: %v\n", err)
|
||||
} else {
|
||||
tmpFile, err = os.CreateTemp(fallbackDir, "reenrich_cover_*.jpg")
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Failed to create cover temp file (fallback dir %s): %v\n", fallbackDir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && tmpFile != nil {
|
||||
coverTempPath = tmpFile.Name()
|
||||
if _, writeErr := tmpFile.Write(coverData); writeErr != nil {
|
||||
GoLog("[ReEnrich] Failed writing cover temp file: %v\n", writeErr)
|
||||
tmpFile.Close()
|
||||
os.Remove(coverTempPath)
|
||||
coverTempPath = ""
|
||||
} else if closeErr := tmpFile.Close(); closeErr != nil {
|
||||
GoLog("[ReEnrich] Failed closing cover temp file: %v\n", closeErr)
|
||||
os.Remove(coverTempPath)
|
||||
coverTempPath = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1570,9 +1764,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
lower := strings.ToLower(req.FilePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
|
||||
// Build enriched metadata response for Dart (includes online search results)
|
||||
enrichedMeta := map[string]interface{}{
|
||||
"track_name": req.TrackName,
|
||||
@@ -1608,8 +1799,24 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
Lyrics: lyricsLRC,
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(req.FilePath, metadata, coverTempPath); err != nil {
|
||||
return "", fmt.Errorf("failed to embed metadata: %w", err)
|
||||
if len(coverDataBytes) > 0 {
|
||||
if err := EmbedMetadataWithCoverData(req.FilePath, metadata, coverDataBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to embed metadata with cover: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := EmbedMetadata(req.FilePath, metadata, ""); err != nil {
|
||||
return "", fmt.Errorf("failed to embed metadata: %w", err)
|
||||
}
|
||||
}
|
||||
if len(coverDataBytes) > 0 {
|
||||
embeddedCover, err := ExtractCoverArt(req.FilePath)
|
||||
if err != nil || len(embeddedCover) == 0 {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("metadata embedded but cover verification failed: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("metadata embedded but cover verification failed: empty embedded cover")
|
||||
}
|
||||
GoLog("[ReEnrich] Cover verified after embed (%d bytes)\n", len(embeddedCover))
|
||||
}
|
||||
|
||||
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
|
||||
@@ -2699,14 +2906,26 @@ func GetStoreCategoriesJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
|
||||
if strings.TrimSpace(extensionID) == "" {
|
||||
return "", fmt.Errorf("invalid extension id")
|
||||
}
|
||||
|
||||
safeExtensionID := sanitizeFilename(extensionID)
|
||||
return filepath.Join(destDir, safeExtensionID+".spotiflac-ext"), nil
|
||||
}
|
||||
|
||||
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
destPath := fmt.Sprintf("%s/%s.spotiflac-ext", destDir, extensionID)
|
||||
err := store.DownloadExtension(extensionID, destPath)
|
||||
destPath, err := buildStoreExtensionDestPath(destDir, extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = store.DownloadExtension(extensionID, destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -1082,16 +1082,18 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||
if amazonErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: amazonResult.FilePath,
|
||||
BitDepth: amazonResult.BitDepth,
|
||||
SampleRate: amazonResult.SampleRate,
|
||||
Title: amazonResult.Title,
|
||||
Artist: amazonResult.Artist,
|
||||
Album: amazonResult.Album,
|
||||
ReleaseDate: amazonResult.ReleaseDate,
|
||||
TrackNumber: amazonResult.TrackNumber,
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
FilePath: amazonResult.FilePath,
|
||||
BitDepth: amazonResult.BitDepth,
|
||||
SampleRate: amazonResult.SampleRate,
|
||||
Title: amazonResult.Title,
|
||||
Artist: amazonResult.Artist,
|
||||
Album: amazonResult.Album,
|
||||
ReleaseDate: amazonResult.ReleaseDate,
|
||||
TrackNumber: amazonResult.TrackNumber,
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
LyricsLRC: amazonResult.LyricsLRC,
|
||||
DecryptionKey: amazonResult.DecryptionKey,
|
||||
}
|
||||
}
|
||||
err = amazonErr
|
||||
@@ -1119,6 +1121,8 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1164,16 +1168,30 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
p.extension.VMMu.Lock()
|
||||
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() {
|
||||
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;
|
||||
})()
|
||||
`, query, string(optionsJSON))
|
||||
`
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
@@ -1358,12 +1376,12 @@ type PostProcessResult struct {
|
||||
}
|
||||
|
||||
type PostProcessInput struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
IsSAF bool `json:"is_saf,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
IsSAF bool `json:"is_saf,omitempty"`
|
||||
}
|
||||
|
||||
const PostProcessTimeout = 2 * time.Minute
|
||||
|
||||
@@ -18,6 +18,43 @@ import (
|
||||
|
||||
// ==================== Auth API (OAuth Support) ====================
|
||||
|
||||
func validateExtensionAuthURL(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid auth URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("invalid auth URL: only https is allowed")
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("invalid auth URL: hostname is required")
|
||||
}
|
||||
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("invalid auth URL: embedded credentials are not allowed")
|
||||
}
|
||||
|
||||
if isPrivateIP(host) {
|
||||
return fmt.Errorf("invalid auth URL: private/local network is not allowed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func summarizeURLForLog(urlStr string) string {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return urlStr
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return parsed.Scheme + "://"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -32,6 +69,13 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
callbackURL = call.Arguments[1].String()
|
||||
}
|
||||
|
||||
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
@@ -50,7 +94,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
@@ -273,6 +317,12 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
"error": "authUrl, clientId, and redirectUri are required",
|
||||
})
|
||||
}
|
||||
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
scope, _ := config["scope"].(string)
|
||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||
@@ -331,7 +381,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
@@ -441,13 +491,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
bodyPreview := sanitizeSensitiveLogText(string(body))
|
||||
if len(bodyPreview) > 1000 {
|
||||
bodyPreview = bodyPreview[:1000] + "...[truncated]"
|
||||
}
|
||||
|
||||
var tokenResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||
"body": string(body),
|
||||
"body": bodyPreview,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -468,7 +522,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no access_token in response",
|
||||
"body": string(body),
|
||||
"body": bodyPreview,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("network access denied: only https is allowed")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("invalid URL: embedded credentials are not allowed")
|
||||
}
|
||||
|
||||
domain := parsed.Hostname()
|
||||
if domain == "" {
|
||||
|
||||
@@ -46,7 +46,7 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(storagePath, data, 0644)
|
||||
return os.WriteFile(storagePath, data, 0600)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
@@ -4,6 +4,7 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -49,6 +50,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
IsTimeout: true,
|
||||
}}
|
||||
} 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)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -30,8 +31,22 @@ const (
|
||||
var (
|
||||
globalLogBuffer *LogBuffer
|
||||
logBufferOnce sync.Once
|
||||
|
||||
authorizationBearerPattern = regexp.MustCompile(`(?i)\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||
genericKeyValuePattern = regexp.MustCompile(`(?i)\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)`)
|
||||
queryTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+`)
|
||||
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||
)
|
||||
|
||||
func sanitizeSensitiveLogText(message string) string {
|
||||
redacted := message
|
||||
redacted = authorizationBearerPattern.ReplaceAllString(redacted, "Authorization: Bearer [REDACTED]")
|
||||
redacted = genericKeyValuePattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`)
|
||||
redacted = queryTokenPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
|
||||
redacted = bearerTokenPattern.ReplaceAllString(redacted, "Bearer [REDACTED]")
|
||||
return redacted
|
||||
}
|
||||
|
||||
func GetLogBuffer() *LogBuffer {
|
||||
logBufferOnce.Do(func() {
|
||||
globalLogBuffer = &LogBuffer{
|
||||
@@ -71,6 +86,7 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
return
|
||||
}
|
||||
|
||||
message = sanitizeSensitiveLogText(message)
|
||||
message = truncateLogMessage(message)
|
||||
|
||||
entry := LogEntry{
|
||||
|
||||
+160
-35
@@ -4,8 +4,13 @@ import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
stdimage "image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -14,6 +19,82 @@ import (
|
||||
"github.com/go-flac/go-flac/v2"
|
||||
)
|
||||
|
||||
func detectCoverMIME(coverPath string, coverData []byte) string {
|
||||
// Prefer magic-byte detection over file extension.
|
||||
// Some providers return non-JPEG data behind .jpg URLs.
|
||||
if len(coverData) >= 8 &&
|
||||
coverData[0] == 0x89 &&
|
||||
coverData[1] == 0x50 &&
|
||||
coverData[2] == 0x4E &&
|
||||
coverData[3] == 0x47 &&
|
||||
coverData[4] == 0x0D &&
|
||||
coverData[5] == 0x0A &&
|
||||
coverData[6] == 0x1A &&
|
||||
coverData[7] == 0x0A {
|
||||
return "image/png"
|
||||
}
|
||||
if len(coverData) >= 3 &&
|
||||
coverData[0] == 0xFF &&
|
||||
coverData[1] == 0xD8 &&
|
||||
coverData[2] == 0xFF {
|
||||
return "image/jpeg"
|
||||
}
|
||||
if len(coverData) >= 6 {
|
||||
header := string(coverData[:6])
|
||||
if header == "GIF87a" || header == "GIF89a" {
|
||||
return "image/gif"
|
||||
}
|
||||
}
|
||||
if len(coverData) >= 12 &&
|
||||
string(coverData[:4]) == "RIFF" &&
|
||||
string(coverData[8:12]) == "WEBP" {
|
||||
return "image/webp"
|
||||
}
|
||||
|
||||
switch strings.ToLower(filepath.Ext(strings.TrimSpace(coverPath))) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
}
|
||||
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
|
||||
if len(coverData) == 0 {
|
||||
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
|
||||
}
|
||||
|
||||
mime := detectCoverMIME(coverPath, coverData)
|
||||
picture := &flacpicture.MetadataBlockPicture{
|
||||
PictureType: flacpicture.PictureTypeFrontCover,
|
||||
MIME: mime,
|
||||
Description: "Front Cover",
|
||||
ImageData: coverData,
|
||||
}
|
||||
|
||||
// Width/height/depth are optional in practice; keep zero when decode fails.
|
||||
if cfg, format, err := stdimage.DecodeConfig(bytes.NewReader(coverData)); err == nil {
|
||||
picture.Width = uint32(cfg.Width)
|
||||
picture.Height = uint32(cfg.Height)
|
||||
switch format {
|
||||
case "png":
|
||||
picture.ColorDepth = 32
|
||||
case "jpeg":
|
||||
picture.ColorDepth = 24
|
||||
default:
|
||||
picture.ColorDepth = 0
|
||||
}
|
||||
}
|
||||
|
||||
return picture.Marshal(), nil
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
Title string
|
||||
Artist string
|
||||
@@ -127,19 +208,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
picture, err := flacpicture.NewFromImageData(
|
||||
flacpicture.PictureTypeFrontCover,
|
||||
"Front Cover",
|
||||
coverData,
|
||||
"image/jpeg",
|
||||
)
|
||||
picBlock, err := buildPictureBlock(coverPath, coverData)
|
||||
if err != nil {
|
||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
||||
} else {
|
||||
picBlock := picture.Marshal()
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||
return fmt.Errorf("failed to create picture block: %w", err)
|
||||
}
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
||||
@@ -238,19 +312,12 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
}
|
||||
}
|
||||
|
||||
picture, err := flacpicture.NewFromImageData(
|
||||
flacpicture.PictureTypeFrontCover,
|
||||
"Front Cover",
|
||||
coverData,
|
||||
"image/jpeg",
|
||||
)
|
||||
picBlock, err := buildPictureBlock("", coverData)
|
||||
if err != nil {
|
||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
||||
} else {
|
||||
picBlock := picture.Marshal()
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||
return fmt.Errorf("failed to create picture block: %w", err)
|
||||
}
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
@@ -475,33 +542,91 @@ func EmbedGenreLabel(filePath string, genre, label 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)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
for _, meta := range f.Meta {
|
||||
if meta.Type == flac.VorbisComment {
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if meta.Type != flac.VorbisComment {
|
||||
continue
|
||||
}
|
||||
|
||||
lyrics, err := cmt.Get("LYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
lyrics, err := cmt.Get("LYRICS")
|
||||
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
|
||||
+3
-1
@@ -419,7 +419,7 @@ func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
|
||||
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
||||
formatID := mapJumoQuality(quality)
|
||||
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")
|
||||
|
||||
@@ -428,6 +428,8 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeSensitiveLogText(t *testing.T) {
|
||||
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
|
||||
redacted := sanitizeSensitiveLogText(input)
|
||||
|
||||
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
|
||||
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
|
||||
}
|
||||
if !strings.Contains(redacted, "[REDACTED]") {
|
||||
t.Fatalf("expected redaction marker in output, got: %s", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExtensionAuthURL(t *testing.T) {
|
||||
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
|
||||
t.Fatalf("expected valid auth URL, got error: %v", err)
|
||||
}
|
||||
|
||||
blocked := []string{
|
||||
"http://accounts.example.com/oauth/authorize",
|
||||
"https://user:pass@accounts.example.com/oauth/authorize",
|
||||
"https://localhost/oauth/authorize",
|
||||
}
|
||||
|
||||
for _, rawURL := range blocked {
|
||||
if err := validateExtensionAuthURL(rawURL); err == nil {
|
||||
t.Fatalf("expected URL to be blocked: %s", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.example.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
|
||||
t.Fatal("expected embedded URL credentials to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStoreExtensionDestPath(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
|
||||
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
|
||||
if err != nil {
|
||||
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
|
||||
}
|
||||
|
||||
if !isPathWithinBase(baseDir, destPath) {
|
||||
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
|
||||
}
|
||||
|
||||
baseName := filepath.Base(destPath)
|
||||
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
|
||||
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
|
||||
}
|
||||
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
|
||||
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
|
||||
}
|
||||
|
||||
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
|
||||
t.Fatal("expected empty extension id to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.6.0';
|
||||
static const String buildNumber = '77';
|
||||
static const String version = '3.6.5';
|
||||
static const String buildNumber = '79';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
@@ -17,6 +17,5 @@ class AppInfo {
|
||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||
|
||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||
static const String bmacUrl = 'https://buymeacoffee.com/zarzet';
|
||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||
}
|
||||
|
||||
@@ -928,18 +928,6 @@ abstract class AppLocalizations {
|
||||
/// **'Support'**
|
||||
String get aboutSupport;
|
||||
|
||||
/// Donation link
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Buy me a coffee'**
|
||||
String get aboutBuyMeCoffee;
|
||||
|
||||
/// Subtitle for donation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Support development on Ko-fi'**
|
||||
String get aboutBuyMeCoffeeSubtitle;
|
||||
|
||||
/// Section for app info
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3550,6 +3538,24 @@ abstract class AppLocalizations {
|
||||
/// **'Artist folders use Track Artist only'**
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
|
||||
|
||||
/// Setting - strip featured artists from folder name
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Primary artist only for folders'**
|
||||
String get downloadUsePrimaryArtistOnly;
|
||||
|
||||
/// Subtitle when primary artist only is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'**
|
||||
String get downloadUsePrimaryArtistOnlyEnabled;
|
||||
|
||||
/// Subtitle when primary artist only is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Full artist string used for folder name'**
|
||||
String get downloadUsePrimaryArtistOnlyDisabled;
|
||||
|
||||
/// Setting - output file format
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -5062,6 +5068,12 @@ abstract class AppLocalizations {
|
||||
/// **'Fetch and save lyrics as .lrc file'**
|
||||
String get trackSaveLyricsSubtitle;
|
||||
|
||||
/// Snackbar while saving lyrics to file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Saving lyrics...'**
|
||||
String get trackSaveLyricsProgress;
|
||||
|
||||
/// Menu action - re-embed metadata into audio file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -5133,6 +5145,70 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed: {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
|
||||
|
||||
@@ -469,13 +469,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Spendiere mir einen Kaffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle =>
|
||||
'Unterstütze die Entwicklung auf Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -1960,6 +1953,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2861,6 +2865,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@@ -2904,4 +2911,42 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String trackSaveFailed(String 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';
|
||||
}
|
||||
|
||||
@@ -457,12 +457,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@@ -2889,4 +2897,42 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String trackSaveFailed(String 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';
|
||||
}
|
||||
|
||||
@@ -457,12 +457,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@@ -2889,6 +2897,44 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String trackSaveFailed(String 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`).
|
||||
@@ -3327,12 +3373,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
@override
|
||||
String get aboutSupport => 'Soporte';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Invítame a un café';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Apoyar el desarrollo en Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'Aplicación';
|
||||
|
||||
|
||||
@@ -457,12 +457,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@@ -2889,4 +2897,42 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String trackSaveFailed(String 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';
|
||||
}
|
||||
|
||||
@@ -457,12 +457,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@@ -2889,4 +2897,42 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String trackSaveFailed(String 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';
|
||||
}
|
||||
|
||||
@@ -462,12 +462,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Dukungan';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Belikan saya kopi';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Dukung pengembangan di Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'Aplikasi';
|
||||
|
||||
@@ -1958,6 +1952,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Folder artis hanya memakai Track Artist';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artist dihapus dari nama folder (misal Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Nama artis lengkap dipakai untuk folder';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Simpan Format';
|
||||
|
||||
@@ -2864,6 +2869,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackSaveLyricsSubtitle =>
|
||||
'Ambil dan simpan lirik sebagai file .lrc';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Menyimpan lirik...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Perkaya Ulang Metadata';
|
||||
|
||||
@@ -2908,4 +2916,42 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String trackSaveFailed(String 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';
|
||||
}
|
||||
|
||||
@@ -453,12 +453,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'サポート';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'コーヒーを買ってください';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'アプリ';
|
||||
|
||||
@@ -1933,6 +1927,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => '形式を保存';
|
||||
|
||||
@@ -2832,6 +2837,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@@ -2875,4 +2883,42 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String trackSaveFailed(String 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';
|
||||
}
|
||||
|
||||
@@ -457,12 +457,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@@ -2889,4 +2897,42 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String trackSaveFailed(String 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';
|
||||
}
|
||||
|
||||
@@ -457,12 +457,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@@ -2889,4 +2897,42 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String trackSaveFailed(String 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';
|
||||
}
|
||||
|
||||
@@ -457,12 +457,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@@ -2889,6 +2897,44 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String trackSaveFailed(String 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`).
|
||||
@@ -3326,12 +3372,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
@override
|
||||
String get aboutSupport => 'Apoiar';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Compre-me um café';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Apoie o desenvolvimento na Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'Aplicativo';
|
||||
|
||||
|
||||
@@ -470,12 +470,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Поддержка';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Купить мне кофе';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Поддержать разработку на Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'Приложение';
|
||||
|
||||
@@ -1983,6 +1977,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Формат сохранения';
|
||||
|
||||
@@ -2892,6 +2897,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@@ -2935,4 +2943,42 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String trackSaveFailed(String 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';
|
||||
}
|
||||
|
||||
@@ -464,12 +464,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Destek';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Bana bir kahve ısmarla';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi üzerinden uygulamayı destekle';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'Uygulama';
|
||||
|
||||
@@ -1960,6 +1954,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2861,6 +2866,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@@ -2904,4 +2912,42 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String trackSaveFailed(String 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';
|
||||
}
|
||||
|
||||
@@ -457,12 +457,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@@ -2889,6 +2897,44 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String trackSaveFailed(String 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`).
|
||||
@@ -3337,12 +3383,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -5483,12 +5523,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Spendiere mir einen Kaffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Unterstütze die Entwicklung auf Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
+42
-5
@@ -326,10 +326,6 @@
|
||||
"@aboutSocial": {"description": "Section for social links"},
|
||||
"aboutSupport": "Support",
|
||||
"@aboutSupport": {"description": "Section for support/donation links"},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {"description": "Donation link"},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {"description": "Subtitle for donation"},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {"description": "Section for app info"},
|
||||
"aboutVersion": "Version",
|
||||
@@ -1431,6 +1427,12 @@
|
||||
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
|
||||
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
|
||||
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
||||
"@downloadUsePrimaryArtistOnly": {"description": "Setting - strip featured artists from folder name"},
|
||||
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
|
||||
"@downloadUsePrimaryArtistOnlyEnabled": {"description": "Subtitle when primary artist only is enabled"},
|
||||
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
||||
"@downloadUsePrimaryArtistOnlyDisabled": {"description": "Subtitle when primary artist only is disabled"},
|
||||
"downloadSaveFormat": "Save Format",
|
||||
"@downloadSaveFormat": {"description": "Setting - output file format"},
|
||||
"downloadSelectService": "Select Service",
|
||||
@@ -2144,6 +2146,8 @@
|
||||
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
||||
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
|
||||
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
||||
"trackSaveLyricsProgress": "Saving lyrics...",
|
||||
"@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"},
|
||||
"trackReEnrich": "Re-enrich Metadata",
|
||||
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
|
||||
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
|
||||
@@ -2182,5 +2186,38 @@
|
||||
"placeholders": {
|
||||
"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"}
|
||||
}
|
||||
|
||||
@@ -548,14 +548,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -548,14 +548,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Invítame a un café",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Apoyar el desarrollo en Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "Aplicación",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
+53
-14
@@ -588,14 +588,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Belikan saya kopi",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "Aplikasi",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
@@ -2489,6 +2481,18 @@
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnly": "Hanya artis utama untuk folder",
|
||||
"@downloadUsePrimaryArtistOnly": {
|
||||
"description": "Setting - strip featured artists from folder name"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnlyEnabled": "Featured artist dihapus dari nama folder (misal Justin Bieber, Quavo → Justin Bieber)",
|
||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||
"description": "Subtitle when primary artist only is enabled"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnlyDisabled": "Nama artis lengkap dipakai untuk folder",
|
||||
"@downloadUsePrimaryArtistOnlyDisabled": {
|
||||
"description": "Subtitle when primary artist only is disabled"
|
||||
},
|
||||
"downloadSaveFormat": "Simpan Format",
|
||||
"@downloadSaveFormat": {
|
||||
"description": "Setting - output file format"
|
||||
@@ -3160,11 +3164,13 @@
|
||||
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
|
||||
"trackSaveCoverArtSubtitle": "Simpan cover album sebagai file .jpg",
|
||||
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
|
||||
"trackSaveLyrics": "Simpan Lirik (.lrc)",
|
||||
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
||||
"trackSaveLyricsSubtitle": "Ambil dan simpan lirik sebagai file .lrc",
|
||||
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
||||
"trackReEnrich": "Perkaya Ulang Metadata",
|
||||
"trackSaveLyrics": "Simpan Lirik (.lrc)",
|
||||
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
||||
"trackSaveLyricsSubtitle": "Ambil dan simpan lirik sebagai file .lrc",
|
||||
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
||||
"trackSaveLyricsProgress": "Menyimpan lirik...",
|
||||
"@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"},
|
||||
"trackReEnrich": "Perkaya Ulang Metadata",
|
||||
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
|
||||
"trackReEnrichSubtitle": "Tanamkan ulang metadata tanpa mengunduh ulang",
|
||||
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
|
||||
@@ -3202,5 +3208,38 @@
|
||||
"placeholders": {
|
||||
"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"}
|
||||
}
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "コーヒーを買ってください",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Ko-fi で開発をサポートします",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "アプリ",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -548,14 +548,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -548,14 +548,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Compre-me um café",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Apoie o desenvolvimento na Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "Aplicativo",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Купить мне кофе",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Поддержать разработку на Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "Приложение",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Bana bir kahve ısmarla",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Ko-fi üzerinden uygulamayı destekle",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "Uygulama",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -548,14 +548,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -11,12 +11,21 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
_configureImageCache();
|
||||
|
||||
runApp(
|
||||
ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
|
||||
);
|
||||
}
|
||||
|
||||
void _configureImageCache() {
|
||||
final imageCache = PaintingBinding.instance.imageCache;
|
||||
// Keep memory cache bounded so cover-heavy pages don't retain too many
|
||||
// full-resolution images simultaneously.
|
||||
imageCache.maximumSize = 240;
|
||||
imageCache.maximumSizeBytes = 60 << 20; // 60 MiB
|
||||
}
|
||||
|
||||
/// Widget to eagerly initialize providers that need to load data on startup
|
||||
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
const _EagerInitialization({required this.child});
|
||||
|
||||
@@ -20,6 +20,7 @@ class AppSettings {
|
||||
final bool hasSearchedBefore;
|
||||
final String folderOrganization;
|
||||
final bool useAlbumArtistForFolders;
|
||||
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
|
||||
final String historyViewMode;
|
||||
final String historyFilterMode;
|
||||
final bool askQualityBeforeDownload;
|
||||
@@ -65,6 +66,7 @@ class AppSettings {
|
||||
this.hasSearchedBefore = false,
|
||||
this.folderOrganization = 'none',
|
||||
this.useAlbumArtistForFolders = true,
|
||||
this.usePrimaryArtistOnly = false,
|
||||
this.historyViewMode = 'grid',
|
||||
this.historyFilterMode = 'all',
|
||||
this.askQualityBeforeDownload = true,
|
||||
@@ -109,6 +111,7 @@ class AppSettings {
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
bool? useAlbumArtistForFolders,
|
||||
bool? usePrimaryArtistOnly,
|
||||
String? historyViewMode,
|
||||
String? historyFilterMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
@@ -154,6 +157,8 @@ class AppSettings {
|
||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||
useAlbumArtistForFolders:
|
||||
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly:
|
||||
usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
|
||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||
|
||||
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
||||
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
@@ -70,6 +71,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'historyFilterMode': instance.historyFilterMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
@@ -150,20 +151,37 @@ class DownloadHistoryItem {
|
||||
);
|
||||
|
||||
DownloadHistoryItem copyWith({
|
||||
String? trackName,
|
||||
String? artistName,
|
||||
String? albumName,
|
||||
String? albumArtist,
|
||||
String? coverUrl,
|
||||
String? filePath,
|
||||
String? storageMode,
|
||||
String? downloadTreeUri,
|
||||
String? safRelativeDir,
|
||||
String? safFileName,
|
||||
bool? safRepaired,
|
||||
String? isrc,
|
||||
String? spotifyId,
|
||||
int? trackNumber,
|
||||
int? discNumber,
|
||||
int? duration,
|
||||
String? releaseDate,
|
||||
String? quality,
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
String? genre,
|
||||
String? label,
|
||||
String? copyright,
|
||||
}) {
|
||||
return DownloadHistoryItem(
|
||||
id: id,
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
albumName: albumName,
|
||||
albumArtist: albumArtist,
|
||||
coverUrl: coverUrl,
|
||||
trackName: trackName ?? this.trackName,
|
||||
artistName: artistName ?? this.artistName,
|
||||
albumName: albumName ?? this.albumName,
|
||||
albumArtist: albumArtist ?? this.albumArtist,
|
||||
coverUrl: coverUrl ?? this.coverUrl,
|
||||
filePath: filePath ?? this.filePath,
|
||||
storageMode: storageMode ?? this.storageMode,
|
||||
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
||||
@@ -172,34 +190,29 @@ class DownloadHistoryItem {
|
||||
safRepaired: safRepaired ?? this.safRepaired,
|
||||
service: service,
|
||||
downloadedAt: downloadedAt,
|
||||
isrc: isrc,
|
||||
spotifyId: spotifyId,
|
||||
trackNumber: trackNumber,
|
||||
discNumber: discNumber,
|
||||
duration: duration,
|
||||
releaseDate: releaseDate,
|
||||
quality: quality,
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
genre: genre,
|
||||
label: label,
|
||||
copyright: copyright,
|
||||
isrc: isrc ?? this.isrc,
|
||||
spotifyId: spotifyId ?? this.spotifyId,
|
||||
trackNumber: trackNumber ?? this.trackNumber,
|
||||
discNumber: discNumber ?? this.discNumber,
|
||||
duration: duration ?? this.duration,
|
||||
releaseDate: releaseDate ?? this.releaseDate,
|
||||
quality: quality ?? this.quality,
|
||||
bitDepth: bitDepth ?? this.bitDepth,
|
||||
sampleRate: sampleRate ?? this.sampleRate,
|
||||
genre: genre ?? this.genre,
|
||||
label: label ?? this.label,
|
||||
copyright: copyright ?? this.copyright,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadHistoryState {
|
||||
final List<DownloadHistoryItem> items;
|
||||
final Set<String> _downloadedSpotifyIds;
|
||||
final Map<String, DownloadHistoryItem> _bySpotifyId;
|
||||
final Map<String, DownloadHistoryItem> _byIsrc;
|
||||
|
||||
DownloadHistoryState({this.items = const []})
|
||||
: _downloadedSpotifyIds = items
|
||||
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||
.map((item) => item.spotifyId!)
|
||||
.toSet(),
|
||||
_bySpotifyId = Map.fromEntries(
|
||||
: _bySpotifyId = Map.fromEntries(
|
||||
items
|
||||
.where(
|
||||
(item) => item.spotifyId != null && item.spotifyId!.isNotEmpty,
|
||||
@@ -212,8 +225,7 @@ class DownloadHistoryState {
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
|
||||
bool isDownloaded(String spotifyId) =>
|
||||
_downloadedSpotifyIds.contains(spotifyId);
|
||||
bool isDownloaded(String spotifyId) => _bySpotifyId.containsKey(spotifyId);
|
||||
|
||||
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
|
||||
_bySpotifyId[spotifyId];
|
||||
@@ -462,6 +474,44 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return DownloadHistoryItem.fromJson(json);
|
||||
}
|
||||
|
||||
Future<void> updateMetadataForItem({
|
||||
required String id,
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
required String albumName,
|
||||
String? albumArtist,
|
||||
String? isrc,
|
||||
int? trackNumber,
|
||||
int? discNumber,
|
||||
String? releaseDate,
|
||||
String? genre,
|
||||
String? label,
|
||||
String? copyright,
|
||||
}) async {
|
||||
final index = state.items.indexWhere((item) => item.id == id);
|
||||
if (index < 0) return;
|
||||
|
||||
final current = state.items[index];
|
||||
final updated = current.copyWith(
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
albumName: albumName,
|
||||
albumArtist: albumArtist,
|
||||
isrc: isrc,
|
||||
trackNumber: trackNumber,
|
||||
discNumber: discNumber,
|
||||
releaseDate: releaseDate,
|
||||
genre: genre,
|
||||
label: label,
|
||||
copyright: copyright,
|
||||
);
|
||||
|
||||
final updatedItems = [...state.items];
|
||||
updatedItems[index] = updated;
|
||||
state = state.copyWith(items: updatedItems);
|
||||
await _db.upsert(updated.toJson());
|
||||
}
|
||||
|
||||
/// Remove history entries where the file no longer exists on disk
|
||||
/// Returns the number of orphaned entries removed
|
||||
Future<int> cleanupOrphanedDownloads() async {
|
||||
@@ -469,31 +519,36 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
|
||||
final entries = await _db.getAllEntriesWithPaths();
|
||||
final orphanedIds = <String>[];
|
||||
final pathById = <String, String>{};
|
||||
const checkChunkSize = 16;
|
||||
|
||||
for (final entry in entries) {
|
||||
final id = entry['id'] as String;
|
||||
final filePath = entry['file_path'] as String?;
|
||||
for (var i = 0; i < entries.length; i += checkChunkSize) {
|
||||
final end = (i + checkChunkSize < entries.length)
|
||||
? i + checkChunkSize
|
||||
: entries.length;
|
||||
final chunk = entries.sublist(i, end);
|
||||
|
||||
if (filePath == null || filePath.isEmpty) continue;
|
||||
final checks = await Future.wait<MapEntry<String, bool>?>(
|
||||
chunk.map((entry) async {
|
||||
final id = entry['id'] as String;
|
||||
final filePath = entry['file_path'] as String?;
|
||||
if (filePath == null || filePath.isEmpty) return null;
|
||||
pathById[id] = filePath;
|
||||
try {
|
||||
return MapEntry(id, await fileExists(filePath));
|
||||
} catch (e) {
|
||||
_historyLog.w('Error checking file existence for $id: $e');
|
||||
return MapEntry(id, false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
bool exists = false;
|
||||
|
||||
if (filePath.startsWith('content://')) {
|
||||
// SAF path - check via platform bridge
|
||||
try {
|
||||
exists = await PlatformBridge.safExists(filePath);
|
||||
} catch (e) {
|
||||
_historyLog.w('Error checking SAF file existence: $e');
|
||||
exists = false;
|
||||
}
|
||||
} else {
|
||||
// Regular file path
|
||||
exists = File(filePath).existsSync();
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
orphanedIds.add(id);
|
||||
_historyLog.d('Found orphaned entry: $id ($filePath)');
|
||||
for (final check in checks) {
|
||||
if (check == null || check.value) continue;
|
||||
orphanedIds.add(check.key);
|
||||
_historyLog.d(
|
||||
'Found orphaned entry: ${check.key} (${pathById[check.key] ?? ''})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,6 +672,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
static const _queueStorageKey = 'download_queue';
|
||||
static const _progressPollingInterval = Duration(milliseconds: 800);
|
||||
static const _queueSchedulingInterval = Duration(milliseconds: 250);
|
||||
static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI.
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
int _totalQueuedAtStart = 0;
|
||||
@@ -625,11 +681,61 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
bool _isLoaded = false;
|
||||
final Set<String> _ensuredDirs = {};
|
||||
int _progressPollingErrorCount = 0;
|
||||
bool _isProgressPollingInFlight = false;
|
||||
String? _lastServiceTrackName;
|
||||
String? _lastServiceArtistName;
|
||||
int _lastServicePercent = -1;
|
||||
int _lastServiceQueueCount = -1;
|
||||
DateTime _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
String? _lastFinalizingTrackName;
|
||||
String? _lastFinalizingArtistName;
|
||||
String? _lastNotifTrackName;
|
||||
String? _lastNotifArtistName;
|
||||
int _lastNotifPercent = -1;
|
||||
int _lastNotifQueueCount = -1;
|
||||
|
||||
double _normalizeProgressForUi(double value) {
|
||||
final clamped = value.clamp(0.0, 1.0).toDouble();
|
||||
if (clamped <= 0) return 0;
|
||||
if (clamped >= 1) return 1;
|
||||
final rounded = double.parse(clamped.toStringAsFixed(2));
|
||||
return rounded == 0 ? 0.01 : rounded;
|
||||
}
|
||||
|
||||
double _normalizeSpeedForUi(double value) {
|
||||
if (value <= 0) return 0;
|
||||
return double.parse(value.toStringAsFixed(1));
|
||||
}
|
||||
|
||||
int _normalizeBytesForUi(int value) {
|
||||
if (value <= 0) return 0;
|
||||
return (value ~/ _bytesUiStep) * _bytesUiStep;
|
||||
}
|
||||
|
||||
bool _shouldUpdateProgressNotification({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
required int progress,
|
||||
required int total,
|
||||
required int queueCount,
|
||||
}) {
|
||||
final safeTotal = total > 0 ? total : 1;
|
||||
final percent = ((progress * 100) / safeTotal).round().clamp(0, 100);
|
||||
final changed =
|
||||
trackName != _lastNotifTrackName ||
|
||||
artistName != _lastNotifArtistName ||
|
||||
percent != _lastNotifPercent ||
|
||||
queueCount != _lastNotifQueueCount;
|
||||
if (!changed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastNotifTrackName = trackName;
|
||||
_lastNotifArtistName = artistName;
|
||||
_lastNotifPercent = percent;
|
||||
_lastNotifQueueCount = queueCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
DownloadQueueState build() {
|
||||
@@ -726,6 +832,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
void _startMultiProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(_progressPollingInterval, (timer) async {
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||
@@ -798,24 +906,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
} else {
|
||||
percentage = progressFromBackend;
|
||||
}
|
||||
final normalizedProgress = _normalizeProgressForUi(percentage);
|
||||
final normalizedSpeed = _normalizeSpeedForUi(speedMBps);
|
||||
final normalizedBytes = _normalizeBytesForUi(bytesReceived);
|
||||
|
||||
progressUpdates[itemId] = _ProgressUpdate(
|
||||
status: DownloadStatus.downloading,
|
||||
progress: percentage,
|
||||
speedMBps: speedMBps,
|
||||
bytesReceived: bytesReceived,
|
||||
progress: normalizedProgress,
|
||||
speedMBps: normalizedSpeed,
|
||||
bytesReceived: normalizedBytes,
|
||||
);
|
||||
|
||||
final mbReceived = bytesReceived / (1024 * 1024);
|
||||
final mbTotal = bytesTotal / (1024 * 1024);
|
||||
if (bytesTotal > 0) {
|
||||
_log.d(
|
||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||
);
|
||||
} else {
|
||||
_log.d(
|
||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||
);
|
||||
if (LogBuffer.loggingEnabled) {
|
||||
final mbReceived = bytesReceived / (1024 * 1024);
|
||||
final mbTotal = bytesTotal / (1024 * 1024);
|
||||
if (bytesTotal > 0) {
|
||||
_log.d(
|
||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||
);
|
||||
} else {
|
||||
_log.d(
|
||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -858,12 +971,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
if (hasFinalizingItem && finalizingTrackName != null) {
|
||||
_notificationService.showDownloadFinalizing(
|
||||
trackName: finalizingTrackName,
|
||||
artistName: finalizingArtistName ?? '',
|
||||
);
|
||||
final safeArtistName = finalizingArtistName ?? '';
|
||||
if (finalizingTrackName != _lastFinalizingTrackName ||
|
||||
safeArtistName != _lastFinalizingArtistName) {
|
||||
_notificationService.showDownloadFinalizing(
|
||||
trackName: finalizingTrackName,
|
||||
artistName: safeArtistName,
|
||||
);
|
||||
_lastFinalizingTrackName = finalizingTrackName;
|
||||
_lastFinalizingArtistName = safeArtistName;
|
||||
}
|
||||
return;
|
||||
}
|
||||
_lastFinalizingTrackName = null;
|
||||
_lastFinalizingArtistName = null;
|
||||
|
||||
if (items.isNotEmpty) {
|
||||
final firstEntry = items.entries.first;
|
||||
@@ -889,19 +1010,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
notifTotal = 100;
|
||||
}
|
||||
|
||||
_notificationService.showDownloadProgress(
|
||||
final safeNotifTotal = notifTotal > 0 ? notifTotal : 1;
|
||||
if (_shouldUpdateProgressNotification(
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
progress: notifProgress,
|
||||
total: notifTotal > 0 ? notifTotal : 1,
|
||||
);
|
||||
total: safeNotifTotal,
|
||||
queueCount: queuedCount,
|
||||
)) {
|
||||
_notificationService.showDownloadProgress(
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
progress: notifProgress,
|
||||
total: safeNotifTotal,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
_maybeUpdateAndroidDownloadService(
|
||||
trackName: firstDownloading.track.name,
|
||||
artistName: firstDownloading.track.artistName,
|
||||
progress: notifProgress,
|
||||
total: notifTotal > 0 ? notifTotal : 1,
|
||||
total: safeNotifTotal,
|
||||
queueCount: queuedCount,
|
||||
);
|
||||
}
|
||||
@@ -913,6 +1043,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Progress polling failed: $e');
|
||||
}
|
||||
} finally {
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -962,11 +1094,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressPollingErrorCount = 0;
|
||||
_isProgressPollingInFlight = false;
|
||||
_lastServiceTrackName = null;
|
||||
_lastServiceArtistName = null;
|
||||
_lastServicePercent = -1;
|
||||
_lastServiceQueueCount = -1;
|
||||
_lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
_lastFinalizingTrackName = null;
|
||||
_lastFinalizingArtistName = null;
|
||||
_lastNotifTrackName = null;
|
||||
_lastNotifArtistName = null;
|
||||
_lastNotifPercent = -1;
|
||||
_lastNotifQueueCount = -1;
|
||||
}
|
||||
|
||||
Future<void> _initOutputDir() async {
|
||||
@@ -1033,11 +1172,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
bool separateSingles = false,
|
||||
String albumFolderStructure = 'artist_album',
|
||||
bool useAlbumArtistForFolders = true,
|
||||
bool usePrimaryArtistOnly = false,
|
||||
}) async {
|
||||
String baseDir = state.outputDir;
|
||||
final folderArtist = useAlbumArtistForFolders
|
||||
var folderArtist = useAlbumArtistForFolders
|
||||
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
|
||||
: track.artistName;
|
||||
if (usePrimaryArtistOnly) {
|
||||
folderArtist = _extractPrimaryArtist(folderArtist);
|
||||
}
|
||||
|
||||
if (separateSingles) {
|
||||
final isSingle = track.isSingle;
|
||||
@@ -1129,6 +1272,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
.trim();
|
||||
}
|
||||
|
||||
static final _featuredArtistPattern = RegExp(
|
||||
r'\s*[,;&]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
String _extractPrimaryArtist(String artist) {
|
||||
final match = _featuredArtistPattern.firstMatch(artist);
|
||||
if (match != null && match.start > 0) {
|
||||
return artist.substring(0, match.start).trim();
|
||||
}
|
||||
return artist;
|
||||
}
|
||||
|
||||
bool _isSafMode(AppSettings settings) {
|
||||
return Platform.isAndroid &&
|
||||
settings.storageMode == 'saf' &&
|
||||
@@ -1152,10 +1308,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
bool separateSingles = false,
|
||||
String albumFolderStructure = 'artist_album',
|
||||
bool useAlbumArtistForFolders = true,
|
||||
bool usePrimaryArtistOnly = false,
|
||||
}) async {
|
||||
final folderArtist = useAlbumArtistForFolders
|
||||
var folderArtist = useAlbumArtistForFolders
|
||||
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
|
||||
: track.artistName;
|
||||
if (usePrimaryArtistOnly) {
|
||||
folderArtist = _extractPrimaryArtist(folderArtist);
|
||||
}
|
||||
|
||||
if (separateSingles) {
|
||||
final isSingle = track.isSingle;
|
||||
@@ -1215,6 +1375,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
return '.opus';
|
||||
}
|
||||
// Amazon stream is delivered as MP4/M4A container (may contain FLAC audio),
|
||||
// so SAF should keep .m4a before decrypt/convert pipeline.
|
||||
if (service.toLowerCase() == 'amazon') {
|
||||
return '.m4a';
|
||||
}
|
||||
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
|
||||
return '.m4a';
|
||||
}
|
||||
@@ -2565,6 +2730,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
separateSingles: settings.separateSingles,
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||
)
|
||||
: '';
|
||||
String? appOutputDir;
|
||||
@@ -2576,6 +2742,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
separateSingles: settings.separateSingles,
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||
);
|
||||
var effectiveOutputDir = initialOutputDir;
|
||||
var effectiveSafMode = isSafMode;
|
||||
@@ -2641,8 +2808,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (spotifyId.startsWith('spotify:track:')) {
|
||||
spotifyId = spotifyId.split(':').last;
|
||||
}
|
||||
_log.d('No Deezer ID, converting from Spotify via SongLink: $spotifyId');
|
||||
final deezerData = await PlatformBridge.convertSpotifyToDeezer('track', spotifyId);
|
||||
_log.d(
|
||||
'No Deezer ID, converting from Spotify via SongLink: $spotifyId',
|
||||
);
|
||||
final deezerData = await PlatformBridge.convertSpotifyToDeezer(
|
||||
'track',
|
||||
spotifyId,
|
||||
);
|
||||
// Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}}
|
||||
final trackData = deezerData['track'];
|
||||
if (trackData is Map<String, dynamic>) {
|
||||
@@ -2652,20 +2824,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.d('Found Deezer track ID via SongLink: $deezerTrackId');
|
||||
} else if (deezerData['id'] != null) {
|
||||
deezerTrackId = deezerData['id'].toString();
|
||||
_log.d('Found Deezer track ID via SongLink (legacy): $deezerTrackId');
|
||||
_log.d(
|
||||
'Found Deezer track ID via SongLink (legacy): $deezerTrackId',
|
||||
);
|
||||
}
|
||||
|
||||
// Enrich track metadata from Deezer response (release_date, isrc, etc.)
|
||||
final deezerReleaseDate = _normalizeOptionalString(trackData['release_date'] as String?);
|
||||
final deezerIsrc = _normalizeOptionalString(trackData['isrc'] as String?);
|
||||
final deezerReleaseDate = _normalizeOptionalString(
|
||||
trackData['release_date'] as String?,
|
||||
);
|
||||
final deezerIsrc = _normalizeOptionalString(
|
||||
trackData['isrc'] as String?,
|
||||
);
|
||||
final deezerTrackNum = trackData['track_number'] as int?;
|
||||
final deezerDiscNum = trackData['disc_number'] as int?;
|
||||
|
||||
final needsEnrich =
|
||||
(trackToDownload.releaseDate == null && deezerReleaseDate != null) ||
|
||||
(trackToDownload.releaseDate == null &&
|
||||
deezerReleaseDate != null) ||
|
||||
(trackToDownload.isrc == null && deezerIsrc != null) ||
|
||||
(!_isValidISRC(trackToDownload.isrc ?? '') && deezerIsrc != null) ||
|
||||
(trackToDownload.trackNumber == null && deezerTrackNum != null) ||
|
||||
(!_isValidISRC(trackToDownload.isrc ?? '') &&
|
||||
deezerIsrc != null) ||
|
||||
(trackToDownload.trackNumber == null &&
|
||||
deezerTrackNum != null) ||
|
||||
(trackToDownload.discNumber == null && deezerDiscNum != null);
|
||||
|
||||
if (needsEnrich) {
|
||||
@@ -2688,7 +2869,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
albumType: trackToDownload.albumType,
|
||||
source: trackToDownload.source,
|
||||
);
|
||||
_log.d('Enriched track from Deezer - date: ${trackToDownload.releaseDate}, ISRC: ${trackToDownload.isrc}, track: ${trackToDownload.trackNumber}, disc: ${trackToDownload.discNumber}');
|
||||
_log.d(
|
||||
'Enriched track from Deezer - date: ${trackToDownload.releaseDate}, ISRC: ${trackToDownload.isrc}, track: ${trackToDownload.trackNumber}, disc: ${trackToDownload.discNumber}',
|
||||
);
|
||||
}
|
||||
} else if (deezerData['id'] != null) {
|
||||
deezerTrackId = deezerData['id'].toString();
|
||||
@@ -2733,129 +2916,64 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final relativeDir = useSaf ? outputDir : '';
|
||||
final fileName = useSaf ? (safFileName ?? '') : '';
|
||||
final outputExt = useSaf ? safOutputExt : '';
|
||||
final isYouTube = item.service == 'youtube';
|
||||
final shouldUseExtensions = !isYouTube && useExtensions;
|
||||
final shouldUseFallback =
|
||||
!isYouTube && !shouldUseExtensions && state.autoFallback;
|
||||
|
||||
// YouTube provider - lossy only, bypasses fallback chain
|
||||
if (item.service == 'youtube') {
|
||||
if (isYouTube) {
|
||||
_log.d('Using YouTube/Cobalt provider for download');
|
||||
_log.d('Quality: $quality (lossy only)');
|
||||
_log.d('Output dir: $outputDir');
|
||||
return PlatformBridge.downloadFromYouTube(
|
||||
trackName: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: quality,
|
||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||
discNumber: trackToDownload.discNumber ?? 1,
|
||||
releaseDate: trackToDownload.releaseDate,
|
||||
itemId: item.id,
|
||||
durationMs: trackToDownload.duration,
|
||||
isrc: trackToDownload.isrc,
|
||||
spotifyId: trackToDownload.id,
|
||||
deezerId: deezerTrackId,
|
||||
storageMode: storageMode,
|
||||
safTreeUri: treeUri,
|
||||
safRelativeDir: relativeDir,
|
||||
safFileName: fileName,
|
||||
safOutputExt: outputExt,
|
||||
);
|
||||
}
|
||||
|
||||
if (useExtensions) {
|
||||
} else if (shouldUseExtensions) {
|
||||
_log.d('Using extension providers for download');
|
||||
_log.d(
|
||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||
);
|
||||
_log.d('Output dir: $outputDir');
|
||||
return PlatformBridge.downloadWithExtensions(
|
||||
isrc: trackToDownload.isrc ?? '',
|
||||
spotifyId: trackToDownload.id,
|
||||
trackName: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: quality,
|
||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||
discNumber: trackToDownload.discNumber ?? 1,
|
||||
releaseDate: trackToDownload.releaseDate,
|
||||
itemId: item.id,
|
||||
durationMs: trackToDownload.duration,
|
||||
source: trackToDownload.source,
|
||||
genre: genre,
|
||||
label: label,
|
||||
lyricsMode: settings.lyricsMode,
|
||||
preferredService: item.service,
|
||||
storageMode: storageMode,
|
||||
safTreeUri: treeUri,
|
||||
safRelativeDir: relativeDir,
|
||||
safFileName: fileName,
|
||||
safOutputExt: outputExt,
|
||||
);
|
||||
}
|
||||
|
||||
if (state.autoFallback) {
|
||||
} else if (shouldUseFallback) {
|
||||
_log.d('Using auto-fallback mode');
|
||||
_log.d(
|
||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||
);
|
||||
_log.d('Output dir: $outputDir');
|
||||
return PlatformBridge.downloadWithFallback(
|
||||
isrc: trackToDownload.isrc ?? '',
|
||||
spotifyId: trackToDownload.id,
|
||||
trackName: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: quality,
|
||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||
discNumber: trackToDownload.discNumber ?? 1,
|
||||
releaseDate: trackToDownload.releaseDate,
|
||||
preferredService: item.service,
|
||||
itemId: item.id,
|
||||
durationMs: trackToDownload.duration,
|
||||
genre: genre,
|
||||
label: label,
|
||||
lyricsMode: settings.lyricsMode,
|
||||
storageMode: storageMode,
|
||||
safTreeUri: treeUri,
|
||||
safRelativeDir: relativeDir,
|
||||
safFileName: fileName,
|
||||
safOutputExt: outputExt,
|
||||
);
|
||||
}
|
||||
_log.d('Output dir: $outputDir');
|
||||
|
||||
return PlatformBridge.downloadTrack(
|
||||
final payload = DownloadRequestPayload(
|
||||
isrc: trackToDownload.isrc ?? '',
|
||||
service: item.service,
|
||||
spotifyId: trackToDownload.id,
|
||||
trackName: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
albumArtist: normalizedAlbumArtist ?? trackToDownload.artistName,
|
||||
coverUrl: trackToDownload.coverUrl ?? '',
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: quality,
|
||||
// Keep prior behavior: non-YouTube paths were implicitly true.
|
||||
embedLyrics: isYouTube ? settings.embedLyrics : true,
|
||||
embedMaxQualityCover: settings.maxQualityCover,
|
||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||
discNumber: trackToDownload.discNumber ?? 1,
|
||||
releaseDate: trackToDownload.releaseDate,
|
||||
releaseDate: trackToDownload.releaseDate ?? '',
|
||||
itemId: item.id,
|
||||
durationMs: trackToDownload.duration,
|
||||
source: trackToDownload.source ?? '',
|
||||
genre: genre ?? '',
|
||||
label: label ?? '',
|
||||
deezerId: deezerTrackId ?? '',
|
||||
lyricsMode: settings.lyricsMode,
|
||||
storageMode: storageMode,
|
||||
safTreeUri: treeUri,
|
||||
safRelativeDir: relativeDir,
|
||||
safFileName: fileName,
|
||||
safOutputExt: outputExt,
|
||||
);
|
||||
|
||||
return PlatformBridge.downloadByStrategy(
|
||||
payload: payload,
|
||||
useExtensions: shouldUseExtensions,
|
||||
useFallback: shouldUseFallback,
|
||||
);
|
||||
}
|
||||
|
||||
result = await runDownload(
|
||||
@@ -2873,6 +2991,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
separateSingles: settings.separateSingles,
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||
);
|
||||
final fallbackResult = await runDownload(
|
||||
useSaf: false,
|
||||
@@ -2937,6 +3056,117 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final actualService =
|
||||
((result['service'] as String?)?.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 mimeType = isContentUriPath
|
||||
? await _getSafMimeType(filePath)
|
||||
@@ -3363,18 +3593,54 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
await File(tempPath).delete();
|
||||
} 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
|
||||
if (!wasExisting &&
|
||||
item.service == 'youtube' &&
|
||||
filePath != null) {
|
||||
if (!wasExisting && item.service == 'youtube' && filePath != null) {
|
||||
final isOpusFile = filePath.endsWith('.opus');
|
||||
final isMp3File = filePath.endsWith('.mp3');
|
||||
|
||||
if (isOpusFile || isMp3File) {
|
||||
_log.i('YouTube download: embedding metadata to ${isOpusFile ? 'Opus' : 'MP3'} file');
|
||||
_log.i(
|
||||
'YouTube download: embedding metadata to ${isOpusFile ? 'Opus' : 'MP3'} file',
|
||||
);
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
@@ -3752,15 +4018,29 @@ final downloadQueueProvider =
|
||||
|
||||
class DownloadQueueLookup {
|
||||
final Map<String, DownloadItem> byTrackId;
|
||||
final Map<String, DownloadItem> byItemId;
|
||||
final List<String> itemIds;
|
||||
|
||||
DownloadQueueLookup._(this.byTrackId);
|
||||
DownloadQueueLookup._({
|
||||
required this.byTrackId,
|
||||
required this.byItemId,
|
||||
required this.itemIds,
|
||||
});
|
||||
|
||||
factory DownloadQueueLookup.fromItems(List<DownloadItem> items) {
|
||||
final map = <String, DownloadItem>{};
|
||||
final byTrackId = <String, DownloadItem>{};
|
||||
final byItemId = <String, DownloadItem>{};
|
||||
final itemIds = <String>[];
|
||||
for (final item in items) {
|
||||
map.putIfAbsent(item.track.id, () => item);
|
||||
byTrackId.putIfAbsent(item.track.id, () => item);
|
||||
byItemId[item.id] = item;
|
||||
itemIds.add(item.id);
|
||||
}
|
||||
return DownloadQueueLookup._(map);
|
||||
return DownloadQueueLookup._(
|
||||
byTrackId: byTrackId,
|
||||
byItemId: byItemId,
|
||||
itemIds: itemIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ final _log = AppLogger('LocalLibrary');
|
||||
|
||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
||||
final _prefs = SharedPreferences.getInstance();
|
||||
|
||||
class LocalLibraryState {
|
||||
final List<LocalLibraryItem> items;
|
||||
@@ -24,9 +25,9 @@ class LocalLibraryState {
|
||||
final bool scanWasCancelled;
|
||||
final DateTime? lastScannedAt;
|
||||
final int excludedDownloadedCount;
|
||||
final Set<String> _isrcSet;
|
||||
final Set<String> _trackKeySet;
|
||||
final Map<String, LocalLibraryItem> _byIsrc;
|
||||
final Map<String, LocalLibraryItem> _byTrackKey;
|
||||
|
||||
LocalLibraryState({
|
||||
this.items = const [],
|
||||
@@ -39,18 +40,22 @@ class LocalLibraryState {
|
||||
this.scanWasCancelled = false,
|
||||
this.lastScannedAt,
|
||||
this.excludedDownloadedCount = 0,
|
||||
}) : _isrcSet = items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => item.isrc!)
|
||||
.toSet(),
|
||||
_trackKeySet = items.map((item) => item.matchKey).toSet(),
|
||||
_byIsrc = Map.fromEntries(
|
||||
items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
Set<String>? trackKeySet,
|
||||
Map<String, LocalLibraryItem>? byIsrc,
|
||||
Map<String, LocalLibraryItem>? byTrackKey,
|
||||
}) : _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(),
|
||||
_byIsrc =
|
||||
byIsrc ??
|
||||
Map.fromEntries(
|
||||
items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
),
|
||||
_byTrackKey =
|
||||
byTrackKey ??
|
||||
Map.fromEntries(items.map((item) => MapEntry(item.matchKey, item)));
|
||||
|
||||
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
|
||||
bool hasIsrc(String isrc) => _byIsrc.containsKey(isrc);
|
||||
|
||||
bool hasTrack(String trackName, String artistName) {
|
||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
@@ -61,7 +66,7 @@ class LocalLibraryState {
|
||||
|
||||
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
|
||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
return items.where((item) => item.matchKey == key).firstOrNull;
|
||||
return _byTrackKey[key];
|
||||
}
|
||||
|
||||
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
||||
@@ -86,8 +91,11 @@ class LocalLibraryState {
|
||||
DateTime? lastScannedAt,
|
||||
int? excludedDownloadedCount,
|
||||
}) {
|
||||
final nextItems = items ?? this.items;
|
||||
final keepDerivedIndex = identical(nextItems, this.items);
|
||||
|
||||
return LocalLibraryState(
|
||||
items: items ?? this.items,
|
||||
items: nextItems,
|
||||
isScanning: isScanning ?? this.isScanning,
|
||||
scanProgress: scanProgress ?? this.scanProgress,
|
||||
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
||||
@@ -98,6 +106,9 @@ class LocalLibraryState {
|
||||
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
||||
excludedDownloadedCount:
|
||||
excludedDownloadedCount ?? this.excludedDownloadedCount,
|
||||
trackKeySet: keepDerivedIndex ? _trackKeySet : null,
|
||||
byIsrc: keepDerivedIndex ? _byIsrc : null,
|
||||
byTrackKey: keepDerivedIndex ? _byTrackKey : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -110,6 +121,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
bool _isLoaded = false;
|
||||
bool _scanCancelRequested = false;
|
||||
int _progressPollingErrorCount = 0;
|
||||
bool _isProgressPollingInFlight = false;
|
||||
|
||||
@override
|
||||
LocalLibraryState build() {
|
||||
@@ -128,13 +140,17 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_isLoaded = true;
|
||||
|
||||
try {
|
||||
final jsonList = await _db.getAll();
|
||||
final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
||||
final dbItemsFuture = _db.getAll();
|
||||
final prefsFuture = _prefs;
|
||||
final jsonList = await dbItemsFuture;
|
||||
final items = jsonList
|
||||
.map((e) => LocalLibraryItem.fromJson(e))
|
||||
.toList(growable: false);
|
||||
|
||||
DateTime? lastScannedAt;
|
||||
var excludedDownloadedCount = 0;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await prefsFuture;
|
||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
||||
@@ -395,16 +411,37 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
void _startProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||
|
||||
state = state.copyWith(
|
||||
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
|
||||
scanCurrentFile: progress['current_file'] as String?,
|
||||
scanTotalFiles: progress['total_files'] as int? ?? 0,
|
||||
scannedFiles: progress['scanned_files'] as int? ?? 0,
|
||||
scanErrorCount: progress['error_count'] as int? ?? 0,
|
||||
final nextProgress =
|
||||
(progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||
0.0,
|
||||
100.0,
|
||||
);
|
||||
final currentFile = progress['current_file'] as String?;
|
||||
final totalFiles = progress['total_files'] as int? ?? 0;
|
||||
final scannedFiles = progress['scanned_files'] as int? ?? 0;
|
||||
final errorCount = progress['error_count'] as int? ?? 0;
|
||||
|
||||
final shouldUpdateState =
|
||||
state.scanProgress != normalizedProgress ||
|
||||
state.scanCurrentFile != currentFile ||
|
||||
state.scanTotalFiles != totalFiles ||
|
||||
state.scannedFiles != scannedFiles ||
|
||||
state.scanErrorCount != errorCount;
|
||||
|
||||
if (shouldUpdateState) {
|
||||
state = state.copyWith(
|
||||
scanProgress: normalizedProgress,
|
||||
scanCurrentFile: currentFile,
|
||||
scanTotalFiles: totalFiles,
|
||||
scannedFiles: scannedFiles,
|
||||
scanErrorCount: errorCount,
|
||||
);
|
||||
}
|
||||
|
||||
if (progress['is_complete'] == true) {
|
||||
_stopProgressPolling();
|
||||
@@ -415,6 +452,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Library scan progress polling failed: $e');
|
||||
}
|
||||
} finally {
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -423,6 +462,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressPollingErrorCount = 0;
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
|
||||
Future<void> cancelScan() async {
|
||||
@@ -560,17 +600,34 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
}
|
||||
|
||||
final paths = legacyPaths
|
||||
.where((path) => !path.startsWith('content://'))
|
||||
.toList(growable: false);
|
||||
const chunkSize = 24;
|
||||
final backfilled = <String, int>{};
|
||||
for (final path in legacyPaths) {
|
||||
if (_scanCancelRequested || path.startsWith('content://')) {
|
||||
continue;
|
||||
|
||||
for (var i = 0; i < paths.length; i += chunkSize) {
|
||||
if (_scanCancelRequested) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
final stat = await File(path).stat();
|
||||
if (stat.type == FileSystemEntityType.file) {
|
||||
backfilled[path] = stat.modified.millisecondsSinceEpoch;
|
||||
final end = (i + chunkSize < paths.length) ? i + chunkSize : paths.length;
|
||||
final chunk = paths.sublist(i, end);
|
||||
final chunkEntries = await Future.wait<MapEntry<String, int>?>(
|
||||
chunk.map((path) async {
|
||||
try {
|
||||
final stat = await File(path).stat();
|
||||
if (stat.type == FileSystemEntityType.file) {
|
||||
return MapEntry(path, stat.modified.millisecondsSinceEpoch);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
for (final entry in chunkEntries) {
|
||||
if (entry != null) {
|
||||
backfilled[entry.key] = entry.value;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return backfilled;
|
||||
}
|
||||
|
||||
@@ -231,6 +231,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUsePrimaryArtistOnly(bool enabled) {
|
||||
state = state.copyWith(usePrimaryArtistOnly: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHistoryViewMode(String mode) {
|
||||
state = state.copyWith(historyViewMode: mode);
|
||||
_saveSettings();
|
||||
|
||||
+243
-110
@@ -26,8 +26,10 @@ class TrackState {
|
||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
||||
final bool hasSearchText; // For back button handling
|
||||
final bool isShowingRecentAccess; // For recent access mode
|
||||
final String? searchExtensionId; // Extension ID used for current search results
|
||||
final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||
final String?
|
||||
searchExtensionId; // Extension ID used for current search results
|
||||
final String?
|
||||
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -52,7 +54,12 @@ class TrackState {
|
||||
this.selectedSearchFilter,
|
||||
});
|
||||
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty) || (searchAlbums != null && searchAlbums!.isNotEmpty) || (searchPlaylists != null && searchPlaylists!.isNotEmpty);
|
||||
bool get hasContent =>
|
||||
tracks.isNotEmpty ||
|
||||
artistAlbums != null ||
|
||||
(searchArtists != null && searchArtists!.isNotEmpty) ||
|
||||
(searchAlbums != null && searchAlbums!.isNotEmpty) ||
|
||||
(searchPlaylists != null && searchPlaylists!.isNotEmpty);
|
||||
|
||||
TrackState copyWith({
|
||||
List<Track>? tracks,
|
||||
@@ -95,9 +102,12 @@ class TrackState {
|
||||
searchAlbums: searchAlbums ?? this.searchAlbums,
|
||||
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
|
||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||
isShowingRecentAccess:
|
||||
isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||
searchExtensionId: searchExtensionId,
|
||||
selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||
selectedSearchFilter: clearSelectedSearchFilter
|
||||
? null
|
||||
: (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -178,6 +188,7 @@ class SearchPlaylist {
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
int _currentRequestId = 0;
|
||||
static const int _maxPreWarmTracksPerRequest = 80;
|
||||
|
||||
@override
|
||||
TrackState build() {
|
||||
@@ -197,39 +208,42 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
|
||||
|
||||
// Retry logic for extension URL handlers (up to 3 attempts)
|
||||
Map<String, dynamic>? result;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
// Check if we got valid data
|
||||
if (result != null && result['type'] == 'track' && result['track'] != null) {
|
||||
if (result != null &&
|
||||
result['type'] == 'track' &&
|
||||
result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final name = trackData['name']?.toString() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
break;
|
||||
}
|
||||
} else if (result != null && (result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||
} else if (result != null &&
|
||||
(result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||
break;
|
||||
} else if (result != null && result['type'] == 'artist') {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
if (attempt < 3) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (result != null) {
|
||||
final type = result['type'] as String?;
|
||||
final extensionId = result['extension_id'] as String?;
|
||||
|
||||
|
||||
if (type == 'track' && result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||
|
||||
|
||||
if (track.name.isEmpty) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
@@ -237,7 +251,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
@@ -245,15 +259,27 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) {
|
||||
} else if ((type == 'album' || type == 'playlist') &&
|
||||
result['tracks'] != null) {
|
||||
final trackList = result['tracks'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId: result['album']?['id'] as String?,
|
||||
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
|
||||
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
||||
albumName:
|
||||
result['name'] as String? ??
|
||||
result['album']?['name'] as String?,
|
||||
playlistName: type == 'playlist'
|
||||
? result['name'] as String?
|
||||
: null,
|
||||
coverUrl: result['cover_url'] as String?,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
@@ -261,17 +287,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'artist' && result['artist'] != null) {
|
||||
final artistData = result['artist'] as Map<String, dynamic>;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
|
||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final topTracksList =
|
||||
artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
|
||||
coverUrl:
|
||||
artistData['image_url'] as String? ??
|
||||
artistData['images'] as String?,
|
||||
headerImageUrl: artistData['header_image'] as String?,
|
||||
monthlyListeners: artistData['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
@@ -282,19 +320,19 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Step 2: Try Deezer URL parsing
|
||||
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
|
||||
_log.i('Detected Deezer URL, parsing...');
|
||||
final parsed = await PlatformBridge.parseDeezerUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
final id = parsed['id'] as String;
|
||||
|
||||
|
||||
final metadata = await PlatformBridge.getDeezerMetadata(type, id);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
if (type == 'track') {
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
@@ -306,7 +344,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'album') {
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -316,9 +356,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final playlistInfo =
|
||||
metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -329,7 +372,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'artist') {
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
@@ -341,33 +386,38 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Step 3: Try Tidal URL parsing
|
||||
if (url.contains('tidal.com')) {
|
||||
_log.i('Detected Tidal URL, parsing...');
|
||||
final parsed = await PlatformBridge.parseTidalUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
final id = parsed['id'] as String;
|
||||
|
||||
|
||||
_log.i('Tidal URL parsed: type=$type, id=$id');
|
||||
|
||||
|
||||
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
|
||||
if (type == 'track') {
|
||||
try {
|
||||
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
|
||||
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(url);
|
||||
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(
|
||||
url,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
final spotifyUrl = conversion['spotify_url'] as String?;
|
||||
final deezerUrl = conversion['deezer_url'] as String?;
|
||||
|
||||
|
||||
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
|
||||
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
|
||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(spotifyUrl);
|
||||
final metadata =
|
||||
await PlatformBridge.getSpotifyMetadataWithFallback(
|
||||
spotifyUrl,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
@@ -378,10 +428,15 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return;
|
||||
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
|
||||
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
|
||||
final deezerParsed = await PlatformBridge.parseDeezerUrl(deezerUrl);
|
||||
final metadata = await PlatformBridge.getDeezerMetadata('track', deezerParsed['id'] as String);
|
||||
final deezerParsed = await PlatformBridge.parseDeezerUrl(
|
||||
deezerUrl,
|
||||
);
|
||||
final metadata = await PlatformBridge.getDeezerMetadata(
|
||||
'track',
|
||||
deezerParsed['id'] as String,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
@@ -395,30 +450,31 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.w('Failed to convert Tidal URL via SongLink: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For album/artist/playlist, not yet supported
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
|
||||
error:
|
||||
'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Step 4: Fall back to Spotify parsing
|
||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
|
||||
Map<String, dynamic> metadata;
|
||||
|
||||
|
||||
try {
|
||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (type == 'track') {
|
||||
@@ -432,7 +488,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'album') {
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -444,7 +502,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'playlist') {
|
||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
@@ -456,7 +516,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'artist') {
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
@@ -468,17 +530,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(String query, {String? metadataSource, String? filterOverride}) async {
|
||||
Future<void> search(
|
||||
String query, {
|
||||
String? metadataSource,
|
||||
String? filterOverride,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
|
||||
// Preserve selected filter during loading
|
||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
|
||||
try {
|
||||
final settings = ref.read(settingsProvider);
|
||||
@@ -494,20 +568,23 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
searchProvider.isNotEmpty;
|
||||
|
||||
final source = metadataSource ?? 'deezer';
|
||||
|
||||
|
||||
_log.i(
|
||||
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
|
||||
);
|
||||
|
||||
|
||||
Map<String, dynamic> results;
|
||||
List<Track> extensionTracks = [];
|
||||
|
||||
|
||||
if (useExtensions) {
|
||||
try {
|
||||
_log.d('Calling extension search API...');
|
||||
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
||||
final extResults = await PlatformBridge.searchTracksWithExtensions(
|
||||
query,
|
||||
limit: 20,
|
||||
);
|
||||
_log.i('Extensions returned ${extResults.length} tracks');
|
||||
|
||||
|
||||
for (final t in extResults) {
|
||||
try {
|
||||
extensionTracks.add(_parseSearchTrack(t));
|
||||
@@ -519,37 +596,52 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.w('Extension search failed, falling back to built-in: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (source == 'deezer') {
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 2, filter: currentFilter);
|
||||
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums');
|
||||
results = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
_log.i(
|
||||
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||
);
|
||||
} else {
|
||||
_log.d('Calling Spotify search API...');
|
||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 2);
|
||||
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||
results = await PlatformBridge.searchSpotifyAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
);
|
||||
_log.i(
|
||||
'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
_log.w('Search request cancelled (requestId=$requestId)');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
final albumList = results['albums'] as List<dynamic>? ?? [];
|
||||
|
||||
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums');
|
||||
|
||||
|
||||
_log.d(
|
||||
'Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
|
||||
);
|
||||
|
||||
final tracks = <Track>[];
|
||||
|
||||
|
||||
tracks.addAll(extensionTracks);
|
||||
|
||||
|
||||
final existingIsrcs = extensionTracks
|
||||
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
||||
.map((t) => t.isrc!)
|
||||
.toSet();
|
||||
|
||||
|
||||
for (int i = 0; i < trackList.length; i++) {
|
||||
final t = trackList[i];
|
||||
try {
|
||||
@@ -566,7 +658,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse track[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final artists = <SearchArtist>[];
|
||||
for (int i = 0; i < artistList.length; i++) {
|
||||
final a = artistList[i];
|
||||
@@ -580,7 +672,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse artist[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final albums = <SearchAlbum>[];
|
||||
for (int i = 0; i < albumList.length; i++) {
|
||||
final a = albumList[i];
|
||||
@@ -594,7 +686,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse album[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final playlistList = results['playlists'] as List<dynamic>? ?? [];
|
||||
final playlists = <SearchPlaylist>[];
|
||||
for (int i = 0; i < playlistList.length; i++) {
|
||||
@@ -609,9 +701,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse playlist[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully');
|
||||
|
||||
|
||||
_log.i(
|
||||
'Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully',
|
||||
);
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: artists,
|
||||
@@ -624,31 +718,45 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.e('Search failed: $e', e, stackTrace);
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
||||
Future<void> customSearch(
|
||||
String extensionId,
|
||||
String query, {
|
||||
Map<String, dynamic>? options,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve filter during loading
|
||||
);
|
||||
|
||||
try {
|
||||
_log.i('Custom search started: extension=$extensionId, query="$query"');
|
||||
|
||||
final results = await PlatformBridge.customSearchWithExtension(extensionId, query, options: options);
|
||||
|
||||
|
||||
final results = await PlatformBridge.customSearchWithExtension(
|
||||
extensionId,
|
||||
query,
|
||||
options: options,
|
||||
);
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
_log.w('Custom search request cancelled (requestId=$requestId)');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_log.i('Custom search returned ${results.length} tracks');
|
||||
|
||||
|
||||
final tracks = <Track>[];
|
||||
for (int i = 0; i < results.length; i++) {
|
||||
final t = results[i];
|
||||
@@ -658,21 +766,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse custom search track[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)');
|
||||
|
||||
|
||||
_log.i(
|
||||
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
|
||||
);
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: [],
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve selected filter
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.e('Custom search failed: $e', e, stackTrace);
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,7 +798,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
if (track.isrc == null || track.isrc!.isEmpty) return;
|
||||
|
||||
try {
|
||||
final availability = await PlatformBridge.checkAvailability(track.id, track.isrc!);
|
||||
final availability = await PlatformBridge.checkAvailability(
|
||||
track.id,
|
||||
track.isrc!,
|
||||
);
|
||||
final updatedTrack = Track(
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
@@ -736,11 +854,14 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
state = state.copyWith(hasSearchText: hasText);
|
||||
}
|
||||
|
||||
|
||||
void setShowingRecentAccess(bool showing) {
|
||||
if (state.isShowingRecentAccess == showing) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isShowingRecentAccess: showing);
|
||||
}
|
||||
|
||||
|
||||
/// Set tracks from a collection (album/playlist) opened from search results
|
||||
void setTracksFromCollection({
|
||||
required List<Track> tracks,
|
||||
@@ -782,9 +903,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (durationValue is double) {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
|
||||
final itemType = data['item_type']?.toString();
|
||||
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
@@ -797,7 +918,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||
source:
|
||||
source ??
|
||||
data['source']?.toString() ??
|
||||
data['provider_id']?.toString(),
|
||||
albumType: data['album_type']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
@@ -849,16 +973,25 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
|
||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||
if (tracksWithIsrc.isEmpty) return;
|
||||
|
||||
final cacheRequests = tracksWithIsrc.map((t) => {
|
||||
'isrc': t.isrc!,
|
||||
'track_name': t.name,
|
||||
'artist_name': t.artistName,
|
||||
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
||||
'service': 'tidal',
|
||||
}).toList();
|
||||
if (tracks.isEmpty) return;
|
||||
final cacheRequests = <Map<String, String>>[];
|
||||
for (final track in tracks) {
|
||||
final isrc = track.isrc;
|
||||
if (isrc == null || isrc.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
cacheRequests.add({
|
||||
'isrc': isrc,
|
||||
'track_name': track.name,
|
||||
'artist_name': track.artistName,
|
||||
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
|
||||
'service': 'tidal',
|
||||
});
|
||||
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cacheRequests.isEmpty) return;
|
||||
|
||||
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
|
||||
}
|
||||
|
||||
@@ -268,6 +268,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
final dpr = MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
).clamp(1.0, 3.0).toDouble();
|
||||
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
|
||||
.round()
|
||||
.clamp(720, 1440)
|
||||
.toInt();
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
@@ -279,6 +286,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: backgroundMemCacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
|
||||
/// Screen to display downloaded tracks from a specific album
|
||||
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||
@@ -32,6 +34,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final Set<String> _selectedIds = {};
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _embeddedCoverRefreshScheduled = false;
|
||||
List<DownloadHistoryItem>? _albumTracksSourceCache;
|
||||
List<DownloadHistoryItem>? _albumTracksCache;
|
||||
List<DownloadHistoryItem>? _discGroupingSourceCache;
|
||||
Map<int, List<DownloadHistoryItem>>? _discGroupingCache;
|
||||
List<int>? _sortedDiscNumbersCache;
|
||||
List<DownloadHistoryItem>? _commonQualitySourceCache;
|
||||
String? _commonQualityCache;
|
||||
List<DownloadHistoryItem>? _embeddedCoverSourceCache;
|
||||
String? _embeddedCoverPathCache;
|
||||
bool _embeddedCoverPathResolved = false;
|
||||
|
||||
String get _albumLookupKey =>
|
||||
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -46,6 +62,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DownloadedAlbumScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.albumName != widget.albumName ||
|
||||
oldWidget.artistName != widget.artistName) {
|
||||
_albumTracksSourceCache = null;
|
||||
_albumTracksCache = null;
|
||||
_invalidateDerivedTrackCaches();
|
||||
}
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
@@ -57,41 +84,74 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
List<DownloadHistoryItem> _getAlbumTracks(
|
||||
List<DownloadHistoryItem> allItems,
|
||||
) {
|
||||
return allItems.where((item) {
|
||||
// Use albumArtist if available and not empty, otherwise artistName
|
||||
final itemArtist =
|
||||
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||
? item.albumArtist!
|
||||
: item.artistName;
|
||||
// Use lowercase for case-insensitive matching
|
||||
final itemKey =
|
||||
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||
final albumKey =
|
||||
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
||||
return itemKey == albumKey;
|
||||
}).toList()..sort((a, b) {
|
||||
// Sort by disc number first, then by track number
|
||||
final aDisc = a.discNumber ?? 1;
|
||||
final bDisc = b.discNumber ?? 1;
|
||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||
final aNum = a.trackNumber ?? 999;
|
||||
final bNum = b.trackNumber ?? 999;
|
||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||
return a.trackName.compareTo(b.trackName);
|
||||
});
|
||||
final cached = _albumTracksCache;
|
||||
if (cached != null && identical(allItems, _albumTracksSourceCache)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final tracks =
|
||||
allItems.where((item) {
|
||||
// Use albumArtist if available and not empty, otherwise artistName
|
||||
final itemArtist =
|
||||
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||
? item.albumArtist!
|
||||
: item.artistName;
|
||||
// Use lowercase for case-insensitive matching
|
||||
final itemKey =
|
||||
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||
return itemKey == _albumLookupKey;
|
||||
}).toList()..sort((a, b) {
|
||||
// Sort by disc number first, then by track number
|
||||
final aDisc = a.discNumber ?? 1;
|
||||
final bDisc = b.discNumber ?? 1;
|
||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||
final aNum = a.trackNumber ?? 999;
|
||||
final bNum = b.trackNumber ?? 999;
|
||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||
return a.trackName.compareTo(b.trackName);
|
||||
});
|
||||
|
||||
_albumTracksSourceCache = allItems;
|
||||
_albumTracksCache = tracks;
|
||||
_invalidateDerivedTrackCaches();
|
||||
return tracks;
|
||||
}
|
||||
|
||||
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
|
||||
void _invalidateDerivedTrackCaches() {
|
||||
_discGroupingSourceCache = null;
|
||||
_discGroupingCache = null;
|
||||
_sortedDiscNumbersCache = null;
|
||||
_commonQualitySourceCache = null;
|
||||
_commonQualityCache = null;
|
||||
_embeddedCoverSourceCache = null;
|
||||
_embeddedCoverPathCache = null;
|
||||
_embeddedCoverPathResolved = false;
|
||||
}
|
||||
|
||||
Map<int, List<DownloadHistoryItem>> _getDiscGroups(
|
||||
List<DownloadHistoryItem> tracks,
|
||||
) {
|
||||
final cached = _discGroupingCache;
|
||||
if (cached != null && identical(tracks, _discGroupingSourceCache)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final discMap = <int, List<DownloadHistoryItem>>{};
|
||||
for (final track in tracks) {
|
||||
final discNumber = track.discNumber ?? 1;
|
||||
discMap.putIfAbsent(discNumber, () => []).add(track);
|
||||
}
|
||||
_discGroupingSourceCache = tracks;
|
||||
_discGroupingCache = discMap;
|
||||
_sortedDiscNumbersCache = discMap.keys.toList()..sort();
|
||||
return discMap;
|
||||
}
|
||||
|
||||
List<int> _getSortedDiscNumbers(List<DownloadHistoryItem> tracks) {
|
||||
_getDiscGroups(tracks);
|
||||
return _sortedDiscNumbersCache ?? const [];
|
||||
}
|
||||
|
||||
void _enterSelectionMode(String itemId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
@@ -152,10 +212,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
if (confirmed == true && mounted) {
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
final idsToDelete = _selectedIds.toList();
|
||||
final tracksById = {for (final track in currentTracks) track.id: track};
|
||||
|
||||
int deletedCount = 0;
|
||||
for (final id in idsToDelete) {
|
||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||
final item = tracksById[id];
|
||||
if (item != null) {
|
||||
try {
|
||||
await deleteFile(item.filePath);
|
||||
@@ -191,10 +252,28 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||
void _onEmbeddedCoverChanged() {
|
||||
if (!mounted || _embeddedCoverRefreshScheduled) return;
|
||||
_embeddedCoverRefreshScheduled = true;
|
||||
_embeddedCoverPathResolved = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_embeddedCoverRefreshScheduled = false;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
||||
final navigator = Navigator.of(context);
|
||||
_precacheCover(item.coverUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
final beforeModTime =
|
||||
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
|
||||
item.filePath,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final result = await navigator.push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
@@ -204,6 +283,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
item.filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
force: result == true,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
}
|
||||
|
||||
void _precacheCover(String? url) {
|
||||
@@ -211,8 +296,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return;
|
||||
}
|
||||
final dpr = MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
).clamp(1.0, 3.0).toDouble();
|
||||
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
|
||||
ResizeImage(
|
||||
CachedNetworkImageProvider(
|
||||
url,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
),
|
||||
context,
|
||||
);
|
||||
}
|
||||
@@ -256,7 +352,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildAppBar(context, colorScheme, tracks),
|
||||
_buildInfoCard(context, colorScheme, tracks),
|
||||
_buildTrackListHeader(context, colorScheme, tracks),
|
||||
_buildTrackList(context, colorScheme, tracks),
|
||||
@@ -285,7 +381,32 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
String? _resolveAlbumEmbeddedCoverPath(List<DownloadHistoryItem> tracks) {
|
||||
if (_embeddedCoverPathResolved &&
|
||||
identical(tracks, _embeddedCoverSourceCache)) {
|
||||
return _embeddedCoverPathCache;
|
||||
}
|
||||
|
||||
_embeddedCoverSourceCache = tracks;
|
||||
_embeddedCoverPathResolved = true;
|
||||
|
||||
if (tracks.isEmpty) {
|
||||
_embeddedCoverPathCache = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
_embeddedCoverPathCache = DownloadedEmbeddedCoverResolver.resolve(
|
||||
tracks.first.filePath,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
return _embeddedCoverPathCache;
|
||||
}
|
||||
|
||||
Widget _buildAppBar(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
List<DownloadHistoryItem> tracks,
|
||||
) {
|
||||
final mediaSize = MediaQuery.of(context).size;
|
||||
final screenWidth = mediaSize.width;
|
||||
final shortestSide = mediaSize.shortestSide;
|
||||
@@ -294,6 +415,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||
final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks);
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
@@ -322,6 +444,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
final dpr = MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
).clamp(1.0, 3.0).toDouble();
|
||||
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
|
||||
.round()
|
||||
.clamp(720, 1440)
|
||||
.toInt();
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
@@ -329,10 +458,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Blurred cover background
|
||||
if (widget.coverUrl != null)
|
||||
if (embeddedCoverPath != null)
|
||||
Image.file(
|
||||
File(embeddedCoverPath),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: backgroundMemCacheWidth,
|
||||
errorBuilder: (_, _, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
)
|
||||
else if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: backgroundMemCacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
@@ -389,7 +527,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: widget.coverUrl != null
|
||||
child: embeddedCoverPath != null
|
||||
? Image.file(
|
||||
File(embeddedCoverPath),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: (coverSize * 2).toInt(),
|
||||
cacheHeight: (coverSize * 2).toInt(),
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.album,
|
||||
size: fallbackIconSize,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: widget.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
@@ -437,6 +590,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
ColorScheme colorScheme,
|
||||
List<DownloadHistoryItem> tracks,
|
||||
) {
|
||||
final commonQuality = _getCommonQuality(tracks);
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -500,22 +655,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_getCommonQuality(tracks) != null)
|
||||
if (commonQuality != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||
color: commonQuality.startsWith('24')
|
||||
? colorScheme.tertiaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_getCommonQuality(tracks)!,
|
||||
commonQuality,
|
||||
style: TextStyle(
|
||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||
color: commonQuality.startsWith('24')
|
||||
? colorScheme.onTertiaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -534,12 +689,30 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
}
|
||||
|
||||
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
|
||||
if (tracks.isEmpty) return null;
|
||||
final firstQuality = tracks.first.quality;
|
||||
if (firstQuality == null) return null;
|
||||
for (final track in tracks) {
|
||||
if (track.quality != firstQuality) return null;
|
||||
if (identical(tracks, _commonQualitySourceCache)) {
|
||||
return _commonQualityCache;
|
||||
}
|
||||
|
||||
if (tracks.isEmpty) {
|
||||
_commonQualitySourceCache = tracks;
|
||||
_commonQualityCache = null;
|
||||
return null;
|
||||
}
|
||||
final firstQuality = tracks.first.quality;
|
||||
if (firstQuality == null) {
|
||||
_commonQualitySourceCache = tracks;
|
||||
_commonQualityCache = null;
|
||||
return null;
|
||||
}
|
||||
for (final track in tracks) {
|
||||
if (track.quality != firstQuality) {
|
||||
_commonQualitySourceCache = tracks;
|
||||
_commonQualityCache = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
_commonQualitySourceCache = tracks;
|
||||
_commonQualityCache = firstQuality;
|
||||
return firstQuality;
|
||||
}
|
||||
|
||||
@@ -585,7 +758,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
ColorScheme colorScheme,
|
||||
List<DownloadHistoryItem> tracks,
|
||||
) {
|
||||
final discMap = _groupTracksByDisc(tracks);
|
||||
final discMap = _getDiscGroups(tracks);
|
||||
|
||||
if (discMap.length <= 1) {
|
||||
return SliverList(
|
||||
@@ -599,7 +772,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
final discNumbers = discMap.keys.toList()..sort();
|
||||
final discNumbers = _getSortedDiscNumbers(tracks);
|
||||
final List<Widget> children = [];
|
||||
|
||||
for (final discNumber in discNumbers) {
|
||||
|
||||
+177
-73
@@ -18,6 +18,7 @@ import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
@@ -34,16 +35,25 @@ class HomeTab extends ConsumerStatefulWidget {
|
||||
|
||||
class _RecentAccessView {
|
||||
final List<RecentAccessItem> uniqueItems;
|
||||
final List<RecentAccessItem> downloadItems;
|
||||
final List<String> downloadIds;
|
||||
final Map<String, String> downloadFilePathByRecentKey;
|
||||
final bool hasHiddenDownloads;
|
||||
|
||||
const _RecentAccessView({
|
||||
required this.uniqueItems,
|
||||
required this.downloadItems,
|
||||
required this.downloadIds,
|
||||
required this.downloadFilePathByRecentKey,
|
||||
required this.hasHiddenDownloads,
|
||||
});
|
||||
}
|
||||
|
||||
class _RecentAlbumAggregate {
|
||||
int count;
|
||||
DownloadHistoryItem mostRecent;
|
||||
|
||||
_RecentAlbumAggregate({required this.count, required this.mostRecent});
|
||||
}
|
||||
|
||||
class _CsvImportOptions {
|
||||
final bool confirmed;
|
||||
final bool skipDownloaded;
|
||||
@@ -57,7 +67,6 @@ class _CsvImportOptions {
|
||||
class _HomeTabState extends ConsumerState<HomeTab>
|
||||
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
||||
final _urlController = TextEditingController();
|
||||
bool _isTyping = false;
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
String? _lastSearchQuery;
|
||||
late final ProviderSubscription<TrackState> _trackStateSub;
|
||||
@@ -74,6 +83,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
List<RecentAccessItem>? _recentAccessItemsCache;
|
||||
Set<String>? _recentAccessHiddenIdsCache;
|
||||
_RecentAccessView? _recentAccessViewCache;
|
||||
bool _embeddedCoverRefreshScheduled = false;
|
||||
List<Extension>? _thumbnailSizesExtensionsCache;
|
||||
Map<String, (double, double)>? _thumbnailSizesCache;
|
||||
|
||||
double _responsiveScale({
|
||||
required BuildContext context,
|
||||
@@ -197,6 +209,27 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<String, (double, double)> _getThumbnailSizesByExtensionId(
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
final cached = _thumbnailSizesCache;
|
||||
if (cached != null &&
|
||||
identical(extensions, _thumbnailSizesExtensionsCache)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final map = <String, (double, double)>{
|
||||
for (final extension in extensions)
|
||||
if (extension.searchBehavior != null)
|
||||
extension.id: extension.searchBehavior!.getThumbnailSize(
|
||||
defaultSize: 56,
|
||||
),
|
||||
};
|
||||
_thumbnailSizesExtensionsCache = extensions;
|
||||
_thumbnailSizesCache = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
void _onSearchFocusChanged() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
@@ -214,7 +247,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
_urlController.text.isNotEmpty &&
|
||||
!_searchFocusNode.hasFocus) {
|
||||
_urlController.clear();
|
||||
setState(() => _isTyping = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,10 +269,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
||||
|
||||
if (text.isNotEmpty && !_isTyping) {
|
||||
setState(() => _isTyping = true);
|
||||
} else if (text.isEmpty && _isTyping) {
|
||||
setState(() => _isTyping = false);
|
||||
if (text.isEmpty) {
|
||||
_liveSearchDebounce?.cancel();
|
||||
return;
|
||||
}
|
||||
@@ -347,7 +376,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
_urlController.clear();
|
||||
_searchFocusNode.unfocus();
|
||||
_lastSearchQuery = null;
|
||||
setState(() => _isTyping = false);
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
}
|
||||
|
||||
@@ -387,7 +415,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
_urlController.clear();
|
||||
setState(() => _isTyping = false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -413,7 +440,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
_urlController.clear();
|
||||
setState(() => _isTyping = false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -435,7 +461,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
_urlController.clear();
|
||||
setState(() => _isTyping = false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -778,13 +803,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
final showLocalLibraryIndicator =
|
||||
localLibrarySettings.$1 && localLibrarySettings.$2;
|
||||
final thumbnailSizesByExtensionId = <String, (double, double)>{
|
||||
for (final extension in extensions)
|
||||
if (extension.searchBehavior != null)
|
||||
extension.id: extension.searchBehavior!.getThumbnailSize(
|
||||
defaultSize: 56,
|
||||
),
|
||||
};
|
||||
final thumbnailSizesByExtensionId = _getThumbnailSizesByExtensionId(
|
||||
extensions,
|
||||
);
|
||||
Extension? currentSearchExtension;
|
||||
List<SearchFilter> searchFilters = [];
|
||||
|
||||
@@ -932,7 +953,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
|
||||
// Search filter bar (only shown when has search results)
|
||||
if (searchFilters.isNotEmpty && hasActualResults && !showRecentAccess)
|
||||
if (searchFilters.isNotEmpty &&
|
||||
hasActualResults &&
|
||||
!showRecentAccess)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSearchFilterBar(
|
||||
searchFilters,
|
||||
@@ -1022,6 +1045,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
}
|
||||
|
||||
void _onEmbeddedCoverChanged() {
|
||||
if (!mounted || _embeddedCoverRefreshScheduled) return;
|
||||
_embeddedCoverRefreshScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_embeddedCoverRefreshScheduled = false;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildRecentDownloads(
|
||||
List<DownloadHistoryItem> items,
|
||||
ColorScheme colorScheme,
|
||||
@@ -1049,6 +1083,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final embeddedCoverPath = DownloadedEmbeddedCoverResolver.resolve(
|
||||
item.filePath,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(item.id),
|
||||
child: GestureDetector(
|
||||
@@ -1060,7 +1098,26 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: item.coverUrl != null
|
||||
child: embeddedCoverPath != null
|
||||
? Image.file(
|
||||
File(embeddedCoverPath),
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: (coverSize * 2).round(),
|
||||
cacheHeight: (coverSize * 2).round(),
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
)
|
||||
: item.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: coverSize,
|
||||
@@ -1115,63 +1172,58 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return cached;
|
||||
}
|
||||
|
||||
final albumGroups = <String, List<DownloadHistoryItem>>{};
|
||||
final albumGroups = <String, _RecentAlbumAggregate>{};
|
||||
for (final h in historyItems) {
|
||||
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
|
||||
? h.albumArtist!
|
||||
: h.artistName;
|
||||
final albumKey = '${h.albumName}|$artistForKey';
|
||||
albumGroups.putIfAbsent(albumKey, () => []).add(h);
|
||||
final existing = albumGroups[albumKey];
|
||||
if (existing == null) {
|
||||
albumGroups[albumKey] = _RecentAlbumAggregate(count: 1, mostRecent: h);
|
||||
} else {
|
||||
existing.count++;
|
||||
if (h.downloadedAt.isAfter(existing.mostRecent.downloadedAt)) {
|
||||
existing.mostRecent = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final downloadItems = <RecentAccessItem>[];
|
||||
for (final entry in albumGroups.entries) {
|
||||
final tracks = entry.value;
|
||||
final mostRecent = tracks.reduce(
|
||||
(a, b) => a.downloadedAt.isAfter(b.downloadedAt) ? a : b,
|
||||
);
|
||||
final downloadIds = <String>[];
|
||||
final visibleDownloads = <RecentAccessItem>[];
|
||||
final downloadFilePathByRecentKey = <String, String>{};
|
||||
for (final aggregate in albumGroups.values) {
|
||||
final mostRecent = aggregate.mostRecent;
|
||||
final artistForKey =
|
||||
(mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty)
|
||||
? mostRecent.albumArtist!
|
||||
: mostRecent.artistName;
|
||||
|
||||
if (tracks.length == 1) {
|
||||
downloadItems.add(
|
||||
RecentAccessItem(
|
||||
id: mostRecent.spotifyId ?? mostRecent.id,
|
||||
name: mostRecent.trackName,
|
||||
subtitle: mostRecent.artistName,
|
||||
imageUrl: mostRecent.coverUrl,
|
||||
type: RecentAccessType.track,
|
||||
accessedAt: mostRecent.downloadedAt,
|
||||
providerId: 'download',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
downloadItems.add(
|
||||
RecentAccessItem(
|
||||
id: '${mostRecent.albumName}|$artistForKey',
|
||||
name: mostRecent.albumName,
|
||||
subtitle: artistForKey,
|
||||
imageUrl: mostRecent.coverUrl,
|
||||
type: RecentAccessType.album,
|
||||
accessedAt: mostRecent.downloadedAt,
|
||||
providerId: 'download',
|
||||
),
|
||||
);
|
||||
final isSingleTrack = aggregate.count == 1;
|
||||
final recentId = isSingleTrack
|
||||
? (mostRecent.spotifyId ?? mostRecent.id)
|
||||
: '${mostRecent.albumName}|$artistForKey';
|
||||
final recent = RecentAccessItem(
|
||||
id: recentId,
|
||||
name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName,
|
||||
subtitle: isSingleTrack ? mostRecent.artistName : artistForKey,
|
||||
imageUrl: mostRecent.coverUrl,
|
||||
type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album,
|
||||
accessedAt: mostRecent.downloadedAt,
|
||||
providerId: 'download',
|
||||
);
|
||||
|
||||
downloadIds.add(recentId);
|
||||
downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] =
|
||||
mostRecent.filePath;
|
||||
if (!hiddenIds.contains(recentId)) {
|
||||
visibleDownloads.add(recent);
|
||||
}
|
||||
}
|
||||
|
||||
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
||||
|
||||
final visibleDownloads = <RecentAccessItem>[];
|
||||
for (final item in downloadItems) {
|
||||
if (!hiddenIds.contains(item.id)) {
|
||||
visibleDownloads.add(item);
|
||||
if (visibleDownloads.length >= 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
||||
if (visibleDownloads.length > 10) {
|
||||
visibleDownloads.removeRange(10, visibleDownloads.length);
|
||||
}
|
||||
|
||||
final allItems = <RecentAccessItem>[...items, ...visibleDownloads];
|
||||
@@ -1191,7 +1243,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
final view = _RecentAccessView(
|
||||
uniqueItems: uniqueItems,
|
||||
downloadItems: downloadItems,
|
||||
downloadIds: downloadIds,
|
||||
downloadFilePathByRecentKey: downloadFilePathByRecentKey,
|
||||
hasHiddenDownloads: hiddenIds.isNotEmpty,
|
||||
);
|
||||
|
||||
@@ -1604,7 +1657,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) {
|
||||
final uniqueItems = view.uniqueItems;
|
||||
final downloadItems = view.downloadItems;
|
||||
final downloadIds = view.downloadIds;
|
||||
final hasHiddenDownloads = view.hasHiddenDownloads;
|
||||
|
||||
return Padding(
|
||||
@@ -1624,10 +1677,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (uniqueItems.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
for (final item in downloadItems) {
|
||||
for (final id in downloadIds) {
|
||||
ref
|
||||
.read(recentAccessProvider.notifier)
|
||||
.hideDownloadFromRecents(item.id);
|
||||
.hideDownloadFromRecents(id);
|
||||
}
|
||||
ref.read(recentAccessProvider.notifier).clearHistory();
|
||||
},
|
||||
@@ -1680,7 +1733,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
)
|
||||
else
|
||||
...uniqueItems.map(
|
||||
(item) => _buildRecentAccessItem(item, colorScheme),
|
||||
(item) => _buildRecentAccessItem(
|
||||
item,
|
||||
colorScheme,
|
||||
view.downloadFilePathByRecentKey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1690,10 +1747,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
Widget _buildRecentAccessItem(
|
||||
RecentAccessItem item,
|
||||
ColorScheme colorScheme,
|
||||
Map<String, String> downloadFilePathByRecentKey,
|
||||
) {
|
||||
IconData typeIcon;
|
||||
String typeLabel;
|
||||
final isDownloaded = item.providerId == 'download';
|
||||
final embeddedCoverPath = isDownloaded
|
||||
? DownloadedEmbeddedCoverResolver.resolve(
|
||||
downloadFilePathByRecentKey['${item.type.name}:${item.id}'],
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
)
|
||||
: null;
|
||||
|
||||
switch (item.type) {
|
||||
case RecentAccessType.artist:
|
||||
@@ -1723,7 +1787,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
borderRadius: BorderRadius.circular(
|
||||
item.type == RecentAccessType.artist ? 28 : 4,
|
||||
),
|
||||
child: item.imageUrl != null && item.imageUrl!.isNotEmpty
|
||||
child: embeddedCoverPath != null
|
||||
? Image.file(
|
||||
File(embeddedCoverPath),
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 112,
|
||||
cacheHeight: 112,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
typeIcon,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: item.imageUrl != null && item.imageUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.imageUrl!,
|
||||
width: 56,
|
||||
@@ -1896,10 +1978,15 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
||||
final navigator = Navigator.of(context);
|
||||
_precacheCover(item.coverUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
final beforeModTime =
|
||||
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
|
||||
item.filePath,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
@@ -1909,6 +1996,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
item.filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
force: result == true,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
}
|
||||
|
||||
void _precacheCover(String? url) {
|
||||
@@ -1916,8 +2009,19 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return;
|
||||
}
|
||||
final dpr = MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
).clamp(1.0, 3.0).toDouble();
|
||||
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
|
||||
ResizeImage(
|
||||
CachedNetworkImageProvider(
|
||||
url,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
|
||||
late List<int> _sortedDiscNumbersCache;
|
||||
late bool _hasMultipleDiscsCache;
|
||||
String? _commonQualityCache;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -87,6 +88,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
|
||||
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
|
||||
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
|
||||
_commonQualityCache = _computeCommonQuality(_sortedTracksCache);
|
||||
}
|
||||
|
||||
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
|
||||
@@ -160,15 +162,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
if (confirmed == true && mounted) {
|
||||
final libraryNotifier = ref.read(localLibraryProvider.notifier);
|
||||
final idsToDelete = _selectedIds.toList();
|
||||
final tracksById = {for (final track in currentTracks) track.id: track};
|
||||
|
||||
int deletedCount = 0;
|
||||
for (final id in idsToDelete) {
|
||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||
final item = tracksById[id];
|
||||
if (item != null) {
|
||||
try {
|
||||
await deleteFile(item.filePath);
|
||||
} catch (_) {}
|
||||
libraryNotifier.removeItem(id);
|
||||
await libraryNotifier.removeItem(id);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
@@ -425,6 +428,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
ColorScheme colorScheme,
|
||||
List<LocalLibraryItem> tracks,
|
||||
) {
|
||||
final commonQuality = _commonQualityCache;
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -519,22 +524,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Quality badge if all tracks have the same quality
|
||||
if (_getCommonQuality(tracks) != null)
|
||||
if (commonQuality != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getCommonQuality(tracks)!.contains('24')
|
||||
color: commonQuality.contains('24')
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_getCommonQuality(tracks)!,
|
||||
commonQuality,
|
||||
style: TextStyle(
|
||||
color: _getCommonQuality(tracks)!.contains('24')
|
||||
color: commonQuality.contains('24')
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -552,7 +557,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String? _getCommonQuality(List<LocalLibraryItem> tracks) {
|
||||
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
|
||||
if (tracks.isEmpty) return null;
|
||||
final first = tracks.first;
|
||||
if (first.bitDepth == null || first.sampleRate == null) return null;
|
||||
|
||||
@@ -181,6 +181,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
final dpr = MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
).clamp(1.0, 3.0).toDouble();
|
||||
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
|
||||
.round()
|
||||
.clamp(720, 1440)
|
||||
.toInt();
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
@@ -192,6 +199,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: backgroundMemCacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
|
||||
+349
-120
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
||||
@@ -105,6 +106,7 @@ class _GroupedAlbum {
|
||||
final String albumName;
|
||||
final String artistName;
|
||||
final String? coverUrl;
|
||||
final String sampleFilePath;
|
||||
final List<DownloadHistoryItem> tracks;
|
||||
final DateTime latestDownload;
|
||||
final String searchKey;
|
||||
@@ -113,6 +115,7 @@ class _GroupedAlbum {
|
||||
required this.albumName,
|
||||
required this.artistName,
|
||||
this.coverUrl,
|
||||
required this.sampleFilePath,
|
||||
required this.tracks,
|
||||
required this.latestDownload,
|
||||
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
@@ -207,10 +210,25 @@ class _UnifiedCacheEntry {
|
||||
});
|
||||
}
|
||||
|
||||
class _QueueItemIdsSnapshot {
|
||||
final List<String> ids;
|
||||
|
||||
const _QueueItemIdsSnapshot(this.ids);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is _QueueItemIdsSnapshot && listEquals(ids, other.ids);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hashAll(ids);
|
||||
}
|
||||
|
||||
Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
|
||||
final entries = (payload['entries'] as List).cast<List>();
|
||||
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
|
||||
final query = (payload['query'] as String?) ?? '';
|
||||
final hasQuery = query.isNotEmpty;
|
||||
|
||||
final allIds = <String>[];
|
||||
final albumIds = <String>[];
|
||||
@@ -219,10 +237,11 @@ Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
|
||||
for (final entry in entries) {
|
||||
final id = entry[0] as String;
|
||||
final albumKey = entry[1] as String;
|
||||
final searchKey = entry[2] as String;
|
||||
|
||||
if (query.isNotEmpty && !searchKey.contains(query)) {
|
||||
continue;
|
||||
if (hasQuery) {
|
||||
final searchKey = entry[2] as String;
|
||||
if (!searchKey.contains(query)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
allIds.add(id);
|
||||
@@ -259,6 +278,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final ValueNotifier<bool> _alwaysMissingFileNotifier = ValueNotifier(false);
|
||||
final Set<String> _pendingChecks = {};
|
||||
static const int _maxCacheSize = 500;
|
||||
static const int _maxSearchIndexCacheSize = 4000;
|
||||
bool _embeddedCoverRefreshScheduled = false;
|
||||
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
@@ -290,8 +311,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_HistoryStats? _historyStatsCache;
|
||||
final Map<String, String> _searchIndexCache = {};
|
||||
final Map<String, String> _localSearchIndexCache = {};
|
||||
Map<String, DownloadHistoryItem> _historyItemsById = {};
|
||||
List<List<String>> _historyFilterEntries = const [];
|
||||
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
|
||||
List<DownloadHistoryItem>? _filterItemsCache;
|
||||
String _filterQueryCache = '';
|
||||
@@ -379,32 +398,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_historyItemsCache = items;
|
||||
_localLibraryItemsCache = localItems;
|
||||
_historyStatsCache = _buildHistoryStats(items, localItems);
|
||||
_searchIndexCache
|
||||
..clear()
|
||||
..addEntries(
|
||||
items.map((item) => MapEntry(item.id, _buildSearchKey(item))),
|
||||
);
|
||||
if (historyChanged) {
|
||||
_searchIndexCache.clear();
|
||||
}
|
||||
if (localChanged) {
|
||||
_localSearchIndexCache
|
||||
..clear()
|
||||
..addEntries(
|
||||
localItems.map(
|
||||
(item) => MapEntry(item.id, _buildLocalSearchKey(item)),
|
||||
),
|
||||
);
|
||||
_localSearchIndexCache.clear();
|
||||
_localFilterItemsCache = null;
|
||||
_localFilterQueryCache = '';
|
||||
_filteredLocalItemsCache = const [];
|
||||
}
|
||||
_unifiedItemsCache.clear();
|
||||
_historyItemsById = {for (final item in items) item.id: item};
|
||||
_historyFilterEntries = List<List<String>>.generate(items.length, (index) {
|
||||
final item = items[index];
|
||||
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
|
||||
final albumKey =
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
return [item.id, albumKey, searchKey];
|
||||
}, growable: false);
|
||||
|
||||
if (historyChanged) {
|
||||
final validPaths = items
|
||||
.map((item) => _cleanFilePath(item.filePath))
|
||||
.where((path) => path.isNotEmpty)
|
||||
.toSet();
|
||||
DownloadedEmbeddedCoverResolver.invalidatePathsNotIn(validPaths);
|
||||
}
|
||||
_requestFilterRefresh();
|
||||
}
|
||||
|
||||
@@ -418,6 +429,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
String _historySearchKeyForItem(DownloadHistoryItem item) {
|
||||
final cached = _searchIndexCache[item.id];
|
||||
if (cached != null) return cached;
|
||||
|
||||
final searchKey = _buildSearchKey(item);
|
||||
_searchIndexCache[item.id] = searchKey;
|
||||
while (_searchIndexCache.length > _maxSearchIndexCacheSize) {
|
||||
_searchIndexCache.remove(_searchIndexCache.keys.first);
|
||||
}
|
||||
return searchKey;
|
||||
}
|
||||
|
||||
String _localSearchKeyForItem(LocalLibraryItem item) {
|
||||
final cached = _localSearchIndexCache[item.id];
|
||||
if (cached != null) return cached;
|
||||
|
||||
final searchKey = _buildLocalSearchKey(item);
|
||||
_localSearchIndexCache[item.id] = searchKey;
|
||||
while (_localSearchIndexCache.length > _maxSearchIndexCacheSize) {
|
||||
_localSearchIndexCache.remove(_localSearchIndexCache.keys.first);
|
||||
}
|
||||
return searchKey;
|
||||
}
|
||||
|
||||
List<LocalLibraryItem> _filterLocalItems(
|
||||
List<LocalLibraryItem> items,
|
||||
String query,
|
||||
@@ -430,11 +465,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
final filtered = items
|
||||
.where((item) {
|
||||
final searchKey =
|
||||
_localSearchIndexCache[item.id] ?? _buildLocalSearchKey(item);
|
||||
if (!_localSearchIndexCache.containsKey(item.id)) {
|
||||
_localSearchIndexCache[item.id] = searchKey;
|
||||
}
|
||||
final searchKey = _localSearchKeyForItem(item);
|
||||
return searchKey.contains(query);
|
||||
})
|
||||
.toList(growable: false);
|
||||
@@ -507,15 +538,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
final requestId = ++_filterRequestId;
|
||||
final includeSearchKey = query.isNotEmpty;
|
||||
final entries = List<List<String>>.generate(items.length, (index) {
|
||||
final item = items[index];
|
||||
final albumKey =
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
if (!includeSearchKey) {
|
||||
return [item.id, albumKey];
|
||||
}
|
||||
final searchKey = _historySearchKeyForItem(item);
|
||||
return [item.id, albumKey, searchKey];
|
||||
}, growable: false);
|
||||
final payload = <String, Object>{
|
||||
'entries': _historyFilterEntries,
|
||||
'entries': entries,
|
||||
'albumCounts': albumCounts,
|
||||
'query': query,
|
||||
};
|
||||
|
||||
compute(_filterHistoryInIsolate, payload).then((result) {
|
||||
if (!mounted || requestId != _filterRequestId) return;
|
||||
final itemsById = _historyItemsById;
|
||||
final itemsById = {for (final item in items) item.id: item};
|
||||
final filtered = <String, List<DownloadHistoryItem>>{};
|
||||
for (final entry in result.entries) {
|
||||
filtered[entry.key] = entry.value
|
||||
@@ -563,10 +605,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final query = searchQuery;
|
||||
return items
|
||||
.where((item) {
|
||||
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
|
||||
if (!_searchIndexCache.containsKey(item.id)) {
|
||||
_searchIndexCache[item.id] = searchKey;
|
||||
}
|
||||
final searchKey = _historySearchKeyForItem(item);
|
||||
return searchKey.contains(query);
|
||||
})
|
||||
.toList(growable: false);
|
||||
@@ -646,13 +685,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
String _getQualityBadgeText(String quality) {
|
||||
if (quality.contains('bit')) {
|
||||
final q = quality.trim().toLowerCase();
|
||||
if (q.contains('bit')) {
|
||||
return quality.split('/').first;
|
||||
}
|
||||
final bitrateMatch = RegExp(r'(\d+)kbps').firstMatch(quality);
|
||||
if (bitrateMatch != null) {
|
||||
return '${bitrateMatch.group(1)}k';
|
||||
|
||||
// Supports "MP3 320k", "Opus 256kbps", etc.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -682,10 +734,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
if (confirmed == true && mounted) {
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
final localLibraryDb = LibraryDatabase.instance;
|
||||
final itemsById = {for (final item in allItems) item.id: item};
|
||||
|
||||
int deletedCount = 0;
|
||||
for (final id in _selectedIds) {
|
||||
final item = allItems.where((e) => e.id == id).firstOrNull;
|
||||
final item = itemsById[id];
|
||||
if (item != null) {
|
||||
try {
|
||||
final cleanPath = _cleanFilePath(item.filePath);
|
||||
@@ -725,11 +778,42 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
/// Strip EXISTS: prefix from file path (legacy history items)
|
||||
String _cleanFilePath(String? filePath) {
|
||||
if (filePath == null) return '';
|
||||
if (filePath.startsWith('EXISTS:')) {
|
||||
return filePath.substring(7);
|
||||
}
|
||||
return filePath;
|
||||
return DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
|
||||
}
|
||||
|
||||
Future<int?> _readFileModTimeMillis(String? filePath) async {
|
||||
return DownloadedEmbeddedCoverResolver.readFileModTimeMillis(filePath);
|
||||
}
|
||||
|
||||
void _onEmbeddedCoverChanged() {
|
||||
if (!mounted || _embeddedCoverRefreshScheduled) return;
|
||||
_embeddedCoverRefreshScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_embeddedCoverRefreshScheduled = false;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||
String? filePath, {
|
||||
int? beforeModTime,
|
||||
bool force = false,
|
||||
}) async {
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
force: force,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
}
|
||||
|
||||
String? _resolveDownloadedEmbeddedCoverPath(String? filePath) {
|
||||
return DownloadedEmbeddedCoverResolver.resolve(
|
||||
filePath,
|
||||
onChanged: _onEmbeddedCoverChanged,
|
||||
);
|
||||
}
|
||||
|
||||
ValueListenable<bool> _fileExistsListenable(String? filePath) {
|
||||
@@ -804,6 +888,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
});
|
||||
}
|
||||
|
||||
String _fileExtLower(String filePath) {
|
||||
final dotIndex = filePath.lastIndexOf('.');
|
||||
if (dotIndex < 0 || dotIndex == filePath.length - 1) {
|
||||
return '';
|
||||
}
|
||||
return filePath.substring(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
String? _localQualityLabel(LocalLibraryItem item) {
|
||||
if (item.bitDepth == null || item.sampleRate == null) {
|
||||
return null;
|
||||
}
|
||||
return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
|
||||
}
|
||||
|
||||
List<UnifiedLibraryItem> _applyAdvancedFilters(
|
||||
List<UnifiedLibraryItem> items,
|
||||
) {
|
||||
@@ -841,7 +940,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
if (_filterFormat != null) {
|
||||
final ext = item.filePath.split('.').last.toLowerCase();
|
||||
final ext = _fileExtLower(item.filePath);
|
||||
if (ext != _filterFormat) return false;
|
||||
}
|
||||
|
||||
@@ -897,7 +996,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
/// Check if a file path passes the current format filter
|
||||
bool _passesFormatFilter(String filePath) {
|
||||
if (_filterFormat == null) return true;
|
||||
return filePath.split('.').last.toLowerCase() == _filterFormat;
|
||||
return _fileExtLower(filePath) == _filterFormat;
|
||||
}
|
||||
|
||||
/// Filter grouped download albums by search query + advanced filters
|
||||
@@ -922,15 +1021,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
// Filter tracks within the album by advanced filters
|
||||
if (_filterQuality != null || _filterFormat != null) {
|
||||
final filteredTracks = album.tracks
|
||||
.where((track) {
|
||||
if (!_passesQualityFilter(track.quality)) return false;
|
||||
if (!_passesFormatFilter(track.filePath)) return false;
|
||||
return true;
|
||||
})
|
||||
.toList(growable: false);
|
||||
var hasMatchingTrack = false;
|
||||
for (final track in album.tracks) {
|
||||
if (!_passesQualityFilter(track.quality)) continue;
|
||||
if (!_passesFormatFilter(track.filePath)) continue;
|
||||
hasMatchingTrack = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (filteredTracks.isEmpty) continue;
|
||||
if (!hasMatchingTrack) continue;
|
||||
}
|
||||
|
||||
result.add(album);
|
||||
@@ -979,20 +1078,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
// Filter tracks within the album by advanced filters
|
||||
if (_filterQuality != null || _filterFormat != null) {
|
||||
final filteredTracks = album.tracks
|
||||
.where((track) {
|
||||
String? quality;
|
||||
if (track.bitDepth != null && track.sampleRate != null) {
|
||||
quality =
|
||||
'${track.bitDepth}bit/${(track.sampleRate! / 1000).toStringAsFixed(1)}kHz';
|
||||
}
|
||||
if (!_passesQualityFilter(quality)) return false;
|
||||
if (!_passesFormatFilter(track.filePath)) return false;
|
||||
return true;
|
||||
})
|
||||
.toList(growable: false);
|
||||
var hasMatchingTrack = false;
|
||||
for (final track in album.tracks) {
|
||||
if (!_passesQualityFilter(_localQualityLabel(track))) continue;
|
||||
if (!_passesFormatFilter(track.filePath)) continue;
|
||||
hasMatchingTrack = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (filteredTracks.isEmpty) continue;
|
||||
if (!hasMatchingTrack) continue;
|
||||
}
|
||||
|
||||
result.add(album);
|
||||
@@ -1022,7 +1116,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
|
||||
final formats = <String>{};
|
||||
for (final item in items) {
|
||||
final ext = item.filePath.split('.').last.toLowerCase();
|
||||
final ext = _fileExtLower(item.filePath);
|
||||
if (['flac', 'mp3', 'm4a', 'opus', 'ogg', 'wav', 'aiff'].contains(ext)) {
|
||||
formats.add(ext);
|
||||
}
|
||||
@@ -1274,13 +1368,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return;
|
||||
}
|
||||
final dpr = MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
).clamp(1.0, 3.0).toDouble();
|
||||
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
|
||||
ResizeImage(
|
||||
CachedNetworkImageProvider(
|
||||
url,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToMetadataScreen(DownloadItem item) {
|
||||
Future<void> _navigateToMetadataScreen(DownloadItem item) async {
|
||||
final historyItem = ref
|
||||
.read(downloadHistoryProvider)
|
||||
.items
|
||||
@@ -1298,10 +1403,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
);
|
||||
|
||||
final navigator = Navigator.of(context);
|
||||
_precacheCover(historyItem.coverUrl);
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.push(
|
||||
context,
|
||||
final beforeModTime = await _readFileModTimeMillis(historyItem.filePath);
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
@@ -1310,14 +1417,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
).then((_) => _searchFocusNode.unfocus());
|
||||
);
|
||||
_searchFocusNode.unfocus();
|
||||
if (result == true) {
|
||||
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||
historyItem.filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
force: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||
historyItem.filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) {
|
||||
Future<void> _navigateToHistoryMetadataScreen(
|
||||
DownloadHistoryItem item,
|
||||
) async {
|
||||
final navigator = Navigator.of(context);
|
||||
_precacheCover(item.coverUrl);
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.push(
|
||||
context,
|
||||
final beforeModTime = await _readFileModTimeMillis(item.filePath);
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
@@ -1326,7 +1450,20 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
).then((_) => _searchFocusNode.unfocus());
|
||||
);
|
||||
_searchFocusNode.unfocus();
|
||||
if (result == true) {
|
||||
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||
item.filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
force: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||
item.filePath,
|
||||
beforeModTime: beforeModTime,
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToLocalMetadataScreen(LocalLibraryItem item) {
|
||||
@@ -1355,10 +1492,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final query = searchQuery;
|
||||
filteredItems = items.where((item) {
|
||||
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
|
||||
if (!_searchIndexCache.containsKey(item.id)) {
|
||||
_searchIndexCache[item.id] = searchKey;
|
||||
}
|
||||
final searchKey = _historySearchKeyForItem(item);
|
||||
return searchKey.contains(query);
|
||||
}).toList();
|
||||
}
|
||||
@@ -1421,6 +1555,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
albumName: tracks.first.albumName,
|
||||
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
|
||||
coverUrl: tracks.first.coverUrl,
|
||||
sampleFilePath: tracks.first.filePath,
|
||||
tracks: tracks,
|
||||
latestDownload: tracks
|
||||
.map((t) => t.downloadedAt)
|
||||
@@ -1544,7 +1679,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_initializePageController();
|
||||
|
||||
final hasQueueItems = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.items.isNotEmpty),
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty),
|
||||
);
|
||||
final allHistoryItems = ref.watch(
|
||||
downloadHistoryProvider.select((s) => s.items),
|
||||
@@ -1572,6 +1707,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_buildHistoryStats(allHistoryItems, localLibraryItems);
|
||||
final groupedAlbums = historyStats.groupedAlbums;
|
||||
final groupedLocalAlbums = historyStats.groupedLocalAlbums;
|
||||
final filteredGroupedAlbums = _filterGroupedAlbums(
|
||||
groupedAlbums,
|
||||
_searchQuery,
|
||||
);
|
||||
final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums(
|
||||
groupedLocalAlbums,
|
||||
_searchQuery,
|
||||
);
|
||||
final albumCount = historyStats.totalAlbumCount;
|
||||
final singleCount = historyStats.totalSingleTracks;
|
||||
final filterDataCache = <String, _FilterContentData>{};
|
||||
@@ -1582,8 +1725,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
() => _computeFilterContentData(
|
||||
filterMode: filterMode,
|
||||
allHistoryItems: allHistoryItems,
|
||||
groupedAlbums: groupedAlbums,
|
||||
groupedLocalAlbums: groupedLocalAlbums,
|
||||
filteredGroupedAlbums: filteredGroupedAlbums,
|
||||
filteredGroupedLocalAlbums: filteredGroupedLocalAlbums,
|
||||
albumCounts: historyStats.albumCounts,
|
||||
localAlbumCounts: historyStats.localAlbumCounts,
|
||||
localLibraryItems: localLibraryItems,
|
||||
@@ -1647,7 +1790,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
|
||||
// Search bar - always at top
|
||||
if (allHistoryItems.isNotEmpty || hasQueueItems || localLibraryItems.isNotEmpty)
|
||||
if (allHistoryItems.isNotEmpty ||
|
||||
hasQueueItems ||
|
||||
localLibraryItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
@@ -1972,8 +2117,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_FilterContentData _computeFilterContentData({
|
||||
required String filterMode,
|
||||
required List<DownloadHistoryItem> allHistoryItems,
|
||||
required List<_GroupedAlbum> groupedAlbums,
|
||||
required List<_GroupedLocalAlbum> groupedLocalAlbums,
|
||||
required List<_GroupedAlbum> filteredGroupedAlbums,
|
||||
required List<_GroupedLocalAlbum> filteredGroupedLocalAlbums,
|
||||
required Map<String, int> albumCounts,
|
||||
required Map<String, int> localAlbumCounts,
|
||||
required List<LocalLibraryItem> localLibraryItems,
|
||||
@@ -1988,16 +2133,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
filterMode: filterMode,
|
||||
);
|
||||
|
||||
final searchQuery = _searchQuery;
|
||||
final filteredGroupedAlbums = _filterGroupedAlbums(
|
||||
groupedAlbums,
|
||||
searchQuery,
|
||||
);
|
||||
final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums(
|
||||
groupedLocalAlbums,
|
||||
searchQuery,
|
||||
);
|
||||
|
||||
final unifiedItems = _getUnifiedItems(
|
||||
filterMode: filterMode,
|
||||
historyItems: historyItems,
|
||||
@@ -2023,7 +2158,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final queueCount = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.items.length),
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.length),
|
||||
);
|
||||
if (queueCount == 0) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
@@ -2054,20 +2189,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
Widget _buildQueueItemsSliver(BuildContext context, ColorScheme colorScheme) {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final queueItems = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.items),
|
||||
final queueIdsSnapshot = ref.watch(
|
||||
downloadQueueLookupProvider.select(
|
||||
(lookup) => _QueueItemIdsSnapshot(lookup.itemIds),
|
||||
),
|
||||
);
|
||||
if (queueItems.isEmpty) {
|
||||
if (queueIdsSnapshot.ids.isEmpty) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = queueItems[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(item.id),
|
||||
child: _buildQueueItem(context, item, colorScheme),
|
||||
final itemId = queueIdsSnapshot.ids[index];
|
||||
return _QueueItemSliverRow(
|
||||
key: ValueKey(itemId),
|
||||
itemId: itemId,
|
||||
colorScheme: colorScheme,
|
||||
itemBuilder: _buildQueueItem,
|
||||
);
|
||||
}, childCount: queueItems.length),
|
||||
}, childCount: queueIdsSnapshot.ids.length),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -2093,7 +2232,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
if (totalTrackCount > 0 && !hasQueueItems && filterMode == 'all')
|
||||
if (totalTrackCount > 0 && filterMode == 'all')
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
@@ -2143,7 +2282,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
if ((filteredGroupedAlbums.isNotEmpty ||
|
||||
filteredGroupedLocalAlbums.isNotEmpty) &&
|
||||
!hasQueueItems &&
|
||||
filterMode == 'albums')
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -2180,7 +2318,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
// Albums empty state with filter button
|
||||
if (filteredGroupedAlbums.isEmpty &&
|
||||
filteredGroupedLocalAlbums.isEmpty &&
|
||||
!hasQueueItems &&
|
||||
filterMode == 'albums' &&
|
||||
(historyItems.isNotEmpty || localLibraryItems.isNotEmpty))
|
||||
SliverToBoxAdapter(
|
||||
@@ -2331,7 +2468,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
|
||||
// Singles filter - show unified items (downloaded + local singles)
|
||||
if (filterMode == 'singles' && !hasQueueItems)
|
||||
if (filterMode == 'singles')
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
@@ -2559,6 +2696,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_GroupedAlbum album,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
|
||||
album.sampleFilePath,
|
||||
);
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToDownloadedAlbum(album),
|
||||
child: Column(
|
||||
@@ -2569,7 +2709,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: album.coverUrl != null
|
||||
child: embeddedCoverPath != null
|
||||
? Image.file(
|
||||
File(embeddedCoverPath),
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
cacheWidth: 300,
|
||||
cacheHeight: 300,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.album,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: album.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: album.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
@@ -2946,13 +3106,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
// show bytes downloaded instead of percentage
|
||||
item.progress > 0
|
||||
? (item.speedMBps > 0
|
||||
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||
: '${(item.progress * 100).toStringAsFixed(0)}%')
|
||||
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||
: '${(item.progress * 100).toStringAsFixed(0)}%')
|
||||
: (item.bytesReceived > 0
|
||||
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||
: (item.speedMBps > 0
|
||||
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||
: 'Starting...')),
|
||||
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||
: (item.speedMBps > 0
|
||||
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||
: 'Starting...')),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
@@ -3139,6 +3299,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
double size,
|
||||
) {
|
||||
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
||||
if (isDownloaded) {
|
||||
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
|
||||
item.filePath,
|
||||
);
|
||||
if (embeddedCoverPath != null) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(embeddedCoverPath),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: (size * 2).toInt(),
|
||||
cacheHeight: (size * 2).toInt(),
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildPlaceholderCover(colorScheme, size, isDownloaded),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Network URL cover (downloaded items)
|
||||
if (item.coverUrl != null) {
|
||||
@@ -3220,6 +3400,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
||||
if (isDownloaded) {
|
||||
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
|
||||
item.filePath,
|
||||
);
|
||||
if (embeddedCoverPath != null) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(embeddedCoverPath),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 200,
|
||||
cacheHeight: 200,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Network URL cover (downloaded items)
|
||||
if (item.coverUrl != null) {
|
||||
@@ -3668,6 +3872,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
class _QueueItemSliverRow extends ConsumerWidget {
|
||||
final String itemId;
|
||||
final ColorScheme colorScheme;
|
||||
final Widget Function(BuildContext, DownloadItem, ColorScheme) itemBuilder;
|
||||
|
||||
const _QueueItemSliverRow({
|
||||
super.key,
|
||||
required this.itemId,
|
||||
required this.colorScheme,
|
||||
required this.itemBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final item = ref.watch(
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.byItemId[itemId]),
|
||||
);
|
||||
if (item == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return RepaintBoundary(child: itemBuilder(context, item, colorScheme));
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterChip extends StatelessWidget {
|
||||
final String label;
|
||||
final int count;
|
||||
|
||||
@@ -26,7 +26,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
if (widget.query.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(trackProvider.notifier).search(widget.query, metadataSource: settings.metadataSource);
|
||||
ref
|
||||
.read(trackProvider.notifier)
|
||||
.search(widget.query, metadataSource: settings.metadataSource);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -41,19 +43,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
final query = _searchController.text.trim();
|
||||
if (query.isNotEmpty) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||
ref
|
||||
.read(trackProvider.notifier)
|
||||
.search(query, metadataSource: settings.metadataSource);
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadTrack(Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(
|
||||
track,
|
||||
settings.defaultService,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added "${track.name}" to queue')),
|
||||
);
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -78,10 +81,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
autofocus: widget.query.isEmpty,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: _search,
|
||||
),
|
||||
IconButton(icon: const Icon(Icons.search), onPressed: _search),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
@@ -92,7 +92,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
trackState.error!,
|
||||
trackState.error!,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
@@ -115,11 +115,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(Icons.search, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Search for tracks',
|
||||
@@ -137,11 +133,13 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 144,
|
||||
memCacheHeight: 144,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
@@ -152,15 +150,18 @@ child: CachedNetworkImage(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
|
||||
@@ -384,6 +384,8 @@ class _ContributorItem extends StatelessWidget {
|
||||
width: 40,
|
||||
height: 40,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 120,
|
||||
memCacheHeight: 120,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (context, url) => Container(
|
||||
width: 40,
|
||||
|
||||
@@ -60,19 +60,30 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
}
|
||||
|
||||
Future<_CacheOverview> _buildOverview() async {
|
||||
final appCacheDir = await getApplicationCacheDirectory();
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appCacheDirFuture = getApplicationCacheDirectory();
|
||||
final tempDirFuture = getTemporaryDirectory();
|
||||
final appSupportDirFuture = getApplicationSupportDirectory();
|
||||
final coverStatsFuture = CoverCacheManager.getStats();
|
||||
final prefsFuture = SharedPreferences.getInstance();
|
||||
final trackCacheEntriesFuture = _getTrackCacheSizeSafe();
|
||||
|
||||
final appCacheDir = await appCacheDirFuture;
|
||||
final tempDir = await tempDirFuture;
|
||||
final appCachePath = p.normalize(appCacheDir.path);
|
||||
final tempPath = p.normalize(tempDir.path);
|
||||
final tempIsSameAsAppCache = appCachePath == tempPath;
|
||||
|
||||
final appCacheStats = await _scanDirectory(Directory(appCachePath));
|
||||
final tempStats = tempIsSameAsAppCache
|
||||
? null
|
||||
: await _scanDirectory(Directory(tempPath));
|
||||
final coverStats = await CoverCacheManager.getStats();
|
||||
final appCacheStatsFuture = _scanDirectory(Directory(appCachePath));
|
||||
final tempStatsFuture = tempIsSameAsAppCache
|
||||
? Future<_DirectoryStats?>.value(null)
|
||||
: _scanDirectory(Directory(tempPath));
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final appSupportDir = await appSupportDirFuture;
|
||||
final libraryCoverStatsFuture = _scanDirectory(
|
||||
Directory('${appSupportDir.path}/library_covers'),
|
||||
);
|
||||
|
||||
final prefs = await prefsFuture;
|
||||
final explorePayload = prefs.getString(_exploreCacheKey);
|
||||
final exploreTs = prefs.getInt(_exploreCacheTsKey);
|
||||
var exploreBytes = 0;
|
||||
@@ -84,16 +95,11 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
}
|
||||
final hasExploreCache = exploreBytes > 0;
|
||||
|
||||
int trackCacheEntries;
|
||||
try {
|
||||
trackCacheEntries = await PlatformBridge.getTrackCacheSize();
|
||||
} catch (_) {
|
||||
trackCacheEntries = 0;
|
||||
}
|
||||
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
|
||||
final libraryCoverStats = await _scanDirectory(libraryCoverDir);
|
||||
final appCacheStats = await appCacheStatsFuture;
|
||||
final tempStats = await tempStatsFuture;
|
||||
final coverStats = await coverStatsFuture;
|
||||
final libraryCoverStats = await libraryCoverStatsFuture;
|
||||
final trackCacheEntries = await trackCacheEntriesFuture;
|
||||
|
||||
return _CacheOverview(
|
||||
appCachePath: appCachePath,
|
||||
@@ -132,16 +138,37 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
return _DirectoryStats(fileCount: fileCount, totalSizeBytes: totalSize);
|
||||
}
|
||||
|
||||
Future<int> _getTrackCacheSizeSafe() async {
|
||||
try {
|
||||
return await PlatformBridge.getTrackCacheSize();
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearDirectoryContents(String path) async {
|
||||
final directory = Directory(path);
|
||||
if (!await directory.exists()) return;
|
||||
|
||||
try {
|
||||
final entities = directory.listSync(followLinks: false);
|
||||
for (final entity in entities) {
|
||||
try {
|
||||
await entity.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
final entities = <FileSystemEntity>[];
|
||||
await for (final entity in directory.list(followLinks: false)) {
|
||||
entities.add(entity);
|
||||
}
|
||||
|
||||
const deleteChunkSize = 24;
|
||||
for (var i = 0; i < entities.length; i += deleteChunkSize) {
|
||||
final end = (i + deleteChunkSize < entities.length)
|
||||
? i + deleteChunkSize
|
||||
: entities.length;
|
||||
final chunk = entities.sublist(i, end);
|
||||
await Future.wait(
|
||||
chunk.map((entity) async {
|
||||
try {
|
||||
await entity.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -583,7 +610,9 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
subtitle: _buildSubtitle(
|
||||
context.l10n.cacheTrackLookupDesc,
|
||||
overview.trackCacheEntries > 0
|
||||
? context.l10n.cacheEntries(overview.trackCacheEntries)
|
||||
? context.l10n.cacheEntries(
|
||||
overview.trackCacheEntries,
|
||||
)
|
||||
: context.l10n.cacheNoData,
|
||||
),
|
||||
trailing: _buildClearTrailing(
|
||||
@@ -611,7 +640,8 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
SettingsItem(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
title: context.l10n.cacheCleanupUnused,
|
||||
subtitle: '${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
|
||||
subtitle:
|
||||
'${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
|
||||
trailing: _buildClearTrailing(
|
||||
'cleanup_unused',
|
||||
_cleanupUnusedData,
|
||||
|
||||
@@ -202,9 +202,11 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
_DonorTile(name: 'J', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'Julian', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
||||
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
|
||||
_DonorTile(
|
||||
name: '283Fabio',
|
||||
name: 'Elias el Autentico',
|
||||
colorScheme: colorScheme,
|
||||
showDivider: false,
|
||||
),
|
||||
@@ -255,21 +257,6 @@ class _DonateLinksCard extends StatelessWidget {
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
_DonateCardItem(
|
||||
title: 'Buy Me a Coffee',
|
||||
subtitle: 'buymeacoffee.com/zarzet',
|
||||
customIcon: const BmacIcon(size: 22, color: Colors.black87),
|
||||
color: const Color(0xFFFFDD00),
|
||||
url: AppInfo.bmacUrl,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 74,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
_DonateCardItem(
|
||||
title: 'GitHub Sponsors',
|
||||
subtitle: 'github.com/sponsors/zarzet',
|
||||
|
||||
@@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
static const _builtInServices = ['tidal', 'qobuz'];
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||
int _androidSdkVersion = 0;
|
||||
bool _hasAllFilesAccess = false;
|
||||
|
||||
@@ -248,7 +248,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
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
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -365,6 +365,18 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
.setUseAlbumArtistForFolders(value),
|
||||
showDivider: false,
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.person_outline,
|
||||
title: context.l10n.downloadUsePrimaryArtistOnly,
|
||||
subtitle: settings.usePrimaryArtistOnly
|
||||
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
|
||||
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
|
||||
value: settings.usePrimaryArtistOnly,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUsePrimaryArtistOnly(value),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1354,6 +1366,7 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
final isExtensionService = ![
|
||||
'tidal',
|
||||
'qobuz',
|
||||
'amazon',
|
||||
].contains(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
? extensionProviders.any((e) => e.id == currentService)
|
||||
@@ -1380,6 +1393,13 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
isSelected: effectiveService == '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) ...[
|
||||
|
||||
+1962
-340
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
||||
class DownloadRequestPayload {
|
||||
final String isrc;
|
||||
final String service;
|
||||
final String spotifyId;
|
||||
final String trackName;
|
||||
final String artistName;
|
||||
final String albumName;
|
||||
final String albumArtist;
|
||||
final String coverUrl;
|
||||
final String outputDir;
|
||||
final String filenameFormat;
|
||||
final String quality;
|
||||
final bool embedLyrics;
|
||||
final bool embedMaxQualityCover;
|
||||
final int trackNumber;
|
||||
final int discNumber;
|
||||
final int totalTracks;
|
||||
final String releaseDate;
|
||||
final String itemId;
|
||||
final int durationMs;
|
||||
final String source;
|
||||
final String genre;
|
||||
final String label;
|
||||
final String copyright;
|
||||
final String tidalId;
|
||||
final String qobuzId;
|
||||
final String deezerId;
|
||||
final String lyricsMode;
|
||||
final bool useExtensions;
|
||||
final bool useFallback;
|
||||
final String storageMode;
|
||||
final String safTreeUri;
|
||||
final String safRelativeDir;
|
||||
final String safFileName;
|
||||
final String safOutputExt;
|
||||
|
||||
const DownloadRequestPayload({
|
||||
this.isrc = '',
|
||||
this.service = '',
|
||||
this.spotifyId = '',
|
||||
required this.trackName,
|
||||
required this.artistName,
|
||||
required this.albumName,
|
||||
this.albumArtist = '',
|
||||
this.coverUrl = '',
|
||||
required this.outputDir,
|
||||
required this.filenameFormat,
|
||||
this.quality = 'LOSSLESS',
|
||||
this.embedLyrics = true,
|
||||
this.embedMaxQualityCover = true,
|
||||
this.trackNumber = 1,
|
||||
this.discNumber = 1,
|
||||
this.totalTracks = 1,
|
||||
this.releaseDate = '',
|
||||
this.itemId = '',
|
||||
this.durationMs = 0,
|
||||
this.source = '',
|
||||
this.genre = '',
|
||||
this.label = '',
|
||||
this.copyright = '',
|
||||
this.tidalId = '',
|
||||
this.qobuzId = '',
|
||||
this.deezerId = '',
|
||||
this.lyricsMode = 'embed',
|
||||
this.useExtensions = false,
|
||||
this.useFallback = false,
|
||||
this.storageMode = 'app',
|
||||
this.safTreeUri = '',
|
||||
this.safRelativeDir = '',
|
||||
this.safFileName = '',
|
||||
this.safOutputExt = '',
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'isrc': isrc,
|
||||
'service': service,
|
||||
'spotify_id': spotifyId,
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
'album_name': albumName,
|
||||
'album_artist': albumArtist,
|
||||
'cover_url': coverUrl,
|
||||
'output_dir': outputDir,
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'track_number': trackNumber,
|
||||
'disc_number': discNumber,
|
||||
'total_tracks': totalTracks,
|
||||
'release_date': releaseDate,
|
||||
'item_id': itemId,
|
||||
'duration_ms': durationMs,
|
||||
'source': source,
|
||||
'genre': genre,
|
||||
'label': label,
|
||||
'copyright': copyright,
|
||||
'tidal_id': tidalId,
|
||||
'qobuz_id': qobuzId,
|
||||
'deezer_id': deezerId,
|
||||
'lyrics_mode': lyricsMode,
|
||||
'use_extensions': useExtensions,
|
||||
'use_fallback': useFallback,
|
||||
'storage_mode': storageMode,
|
||||
'saf_tree_uri': safTreeUri,
|
||||
'saf_relative_dir': safRelativeDir,
|
||||
'saf_file_name': safFileName,
|
||||
'saf_output_ext': safOutputExt,
|
||||
};
|
||||
}
|
||||
|
||||
DownloadRequestPayload withStrategy({
|
||||
bool? useExtensions,
|
||||
bool? useFallback,
|
||||
}) {
|
||||
return DownloadRequestPayload(
|
||||
isrc: isrc,
|
||||
service: service,
|
||||
spotifyId: spotifyId,
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
albumName: albumName,
|
||||
albumArtist: albumArtist,
|
||||
coverUrl: coverUrl,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: filenameFormat,
|
||||
quality: quality,
|
||||
embedLyrics: embedLyrics,
|
||||
embedMaxQualityCover: embedMaxQualityCover,
|
||||
trackNumber: trackNumber,
|
||||
discNumber: discNumber,
|
||||
totalTracks: totalTracks,
|
||||
releaseDate: releaseDate,
|
||||
itemId: itemId,
|
||||
durationMs: durationMs,
|
||||
source: source,
|
||||
genre: genre,
|
||||
label: label,
|
||||
copyright: copyright,
|
||||
tidalId: tidalId,
|
||||
qobuzId: qobuzId,
|
||||
deezerId: deezerId,
|
||||
lyricsMode: lyricsMode,
|
||||
useExtensions: useExtensions ?? this.useExtensions,
|
||||
useFallback: useFallback ?? this.useFallback,
|
||||
storageMode: storageMode,
|
||||
safTreeUri: safTreeUri,
|
||||
safRelativeDir: safRelativeDir,
|
||||
safFileName: safFileName,
|
||||
safOutputExt: safOutputExt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
|
||||
class _EmbeddedCoverCacheEntry {
|
||||
final String previewPath;
|
||||
final int? sourceModTimeMillis;
|
||||
|
||||
const _EmbeddedCoverCacheEntry({
|
||||
required this.previewPath,
|
||||
this.sourceModTimeMillis,
|
||||
});
|
||||
}
|
||||
|
||||
/// Shared resolver for embedded cover previews from downloaded/local files.
|
||||
/// It keeps a bounded in-memory cache and only refreshes extraction
|
||||
/// when the source file changed.
|
||||
class DownloadedEmbeddedCoverResolver {
|
||||
static const int _maxCacheEntries = 180;
|
||||
|
||||
static final LinkedHashMap<String, _EmbeddedCoverCacheEntry> _cache =
|
||||
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
|
||||
static final Set<String> _pendingExtract = <String>{};
|
||||
static final Set<String> _pendingRefresh = <String>{};
|
||||
static final Set<String> _failedExtract = <String>{};
|
||||
|
||||
static String cleanFilePath(String? filePath) {
|
||||
if (filePath == null) return '';
|
||||
if (filePath.startsWith('EXISTS:')) {
|
||||
return filePath.substring(7);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
static Future<int?> readFileModTimeMillis(String? filePath) async {
|
||||
final cleanPath = cleanFilePath(filePath);
|
||||
if (cleanPath.isEmpty) return null;
|
||||
|
||||
if (isContentUri(cleanPath)) {
|
||||
try {
|
||||
final modTimes = await PlatformBridge.getSafFileModTimes([cleanPath]);
|
||||
return modTimes[cleanPath];
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final stat = await File(cleanPath).stat();
|
||||
return stat.modified.millisecondsSinceEpoch;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String? resolve(String? filePath, {VoidCallback? onChanged}) {
|
||||
final cleanPath = cleanFilePath(filePath);
|
||||
if (cleanPath.isEmpty) return null;
|
||||
|
||||
if (_pendingRefresh.remove(cleanPath)) {
|
||||
_ensureCover(cleanPath, forceRefresh: true, onChanged: onChanged);
|
||||
}
|
||||
|
||||
final cached = _cache[cleanPath];
|
||||
if (cached != null) {
|
||||
if (File(cached.previewPath).existsSync()) {
|
||||
_touch(cleanPath, cached);
|
||||
return cached.previewPath;
|
||||
}
|
||||
_cache.remove(cleanPath);
|
||||
_cleanupTempCoverPathSync(cached.previewPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<void> scheduleRefreshForPath(
|
||||
String? filePath, {
|
||||
int? beforeModTime,
|
||||
bool force = false,
|
||||
VoidCallback? onChanged,
|
||||
}) async {
|
||||
final cleanPath = cleanFilePath(filePath);
|
||||
if (cleanPath.isEmpty) return;
|
||||
|
||||
if (!force) {
|
||||
if (beforeModTime == null) return;
|
||||
final afterModTime = await readFileModTimeMillis(cleanPath);
|
||||
if (afterModTime != null && afterModTime == beforeModTime) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_pendingRefresh.add(cleanPath);
|
||||
_failedExtract.remove(cleanPath);
|
||||
onChanged?.call();
|
||||
}
|
||||
|
||||
static void invalidate(String? filePath) {
|
||||
final cleanPath = cleanFilePath(filePath);
|
||||
if (cleanPath.isEmpty) return;
|
||||
|
||||
final cached = _cache.remove(cleanPath);
|
||||
_pendingExtract.remove(cleanPath);
|
||||
_pendingRefresh.remove(cleanPath);
|
||||
_failedExtract.remove(cleanPath);
|
||||
if (cached != null) {
|
||||
_cleanupTempCoverPathSync(cached.previewPath);
|
||||
}
|
||||
}
|
||||
|
||||
static void invalidatePathsNotIn(Set<String> validCleanPaths) {
|
||||
if (validCleanPaths.isEmpty) {
|
||||
final keys = _cache.keys.toList(growable: false);
|
||||
for (final key in keys) {
|
||||
invalidate(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final staleKeys = _cache.keys
|
||||
.where((path) => !validCleanPaths.contains(path))
|
||||
.toList(growable: false);
|
||||
for (final key in staleKeys) {
|
||||
invalidate(key);
|
||||
}
|
||||
}
|
||||
|
||||
static void _touch(String cleanPath, _EmbeddedCoverCacheEntry entry) {
|
||||
_cache
|
||||
..remove(cleanPath)
|
||||
..[cleanPath] = entry;
|
||||
}
|
||||
|
||||
static void _trimCacheIfNeeded() {
|
||||
while (_cache.length > _maxCacheEntries) {
|
||||
final oldestKey = _cache.keys.first;
|
||||
final removed = _cache.remove(oldestKey);
|
||||
if (removed != null) {
|
||||
_cleanupTempCoverPathSync(removed.previewPath);
|
||||
}
|
||||
_pendingExtract.remove(oldestKey);
|
||||
_pendingRefresh.remove(oldestKey);
|
||||
_failedExtract.remove(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
static void _ensureCover(
|
||||
String cleanPath, {
|
||||
bool forceRefresh = false,
|
||||
int? knownModTime,
|
||||
VoidCallback? onChanged,
|
||||
}) {
|
||||
if (cleanPath.isEmpty) return;
|
||||
if (_pendingExtract.contains(cleanPath)) return;
|
||||
if (!forceRefresh && _cache.containsKey(cleanPath)) return;
|
||||
if (!forceRefresh && _failedExtract.contains(cleanPath)) return;
|
||||
|
||||
_pendingExtract.add(cleanPath);
|
||||
Future.microtask(() async {
|
||||
String? outputPath;
|
||||
try {
|
||||
final modTime = knownModTime ?? await readFileModTimeMillis(cleanPath);
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'download_cover_preview_',
|
||||
);
|
||||
outputPath =
|
||||
'${tempDir.path}${Platform.pathSeparator}cover_preview.jpg';
|
||||
final result = await PlatformBridge.extractCoverToFile(
|
||||
cleanPath,
|
||||
outputPath,
|
||||
);
|
||||
|
||||
final hasCover =
|
||||
result['error'] == null && await File(outputPath).exists();
|
||||
if (!hasCover) {
|
||||
_failedExtract.add(cleanPath);
|
||||
_cleanupTempCoverPathSync(outputPath);
|
||||
return;
|
||||
}
|
||||
|
||||
final previous = _cache[cleanPath];
|
||||
final next = _EmbeddedCoverCacheEntry(
|
||||
previewPath: outputPath,
|
||||
sourceModTimeMillis: modTime,
|
||||
);
|
||||
_touch(cleanPath, next);
|
||||
_failedExtract.remove(cleanPath);
|
||||
_trimCacheIfNeeded();
|
||||
|
||||
if (previous != null && previous.previewPath != outputPath) {
|
||||
_cleanupTempCoverPathSync(previous.previewPath);
|
||||
}
|
||||
onChanged?.call();
|
||||
} catch (_) {
|
||||
_failedExtract.add(cleanPath);
|
||||
_cleanupTempCoverPathSync(outputPath);
|
||||
} finally {
|
||||
_pendingExtract.remove(cleanPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static void _cleanupTempCoverPathSync(String? coverPath) {
|
||||
if (coverPath == null || coverPath.isEmpty) return;
|
||||
try {
|
||||
final file = File(coverPath);
|
||||
if (file.existsSync()) {
|
||||
file.deleteSync();
|
||||
}
|
||||
final parent = file.parent;
|
||||
if (parent.existsSync()) {
|
||||
parent.deleteSync(recursive: true);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,48 @@ class FFmpegService {
|
||||
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 {
|
||||
try {
|
||||
final session = await FFmpegKit.execute(command);
|
||||
@@ -77,7 +119,7 @@ class FFmpegService {
|
||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||
|
||||
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);
|
||||
|
||||
@@ -133,6 +175,111 @@ class FFmpegService {
|
||||
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(
|
||||
String inputPath, {
|
||||
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(
|
||||
Map<String, String> vorbisMetadata,
|
||||
) {
|
||||
|
||||
@@ -17,21 +17,21 @@ String? _currentContainerPath;
|
||||
class HistoryDatabase {
|
||||
static final HistoryDatabase instance = HistoryDatabase._init();
|
||||
static Database? _database;
|
||||
|
||||
|
||||
HistoryDatabase._init();
|
||||
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDB('history.db');
|
||||
return _database!;
|
||||
}
|
||||
|
||||
|
||||
Future<Database> _initDB(String fileName) async {
|
||||
final dbPath = await getApplicationDocumentsDirectory();
|
||||
final path = join(dbPath.path, fileName);
|
||||
|
||||
|
||||
_log.i('Initializing database at: $path');
|
||||
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 3,
|
||||
@@ -39,10 +39,10 @@ class HistoryDatabase {
|
||||
onUpgrade: _upgradeDB,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> _createDB(Database db, int version) async {
|
||||
_log.i('Creating database schema v$version');
|
||||
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE history (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -73,16 +73,20 @@ class HistoryDatabase {
|
||||
copyright TEXT
|
||||
)
|
||||
''');
|
||||
|
||||
|
||||
// Indexes for fast lookups
|
||||
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_downloaded_at ON history(downloaded_at DESC)');
|
||||
await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)');
|
||||
|
||||
await db.execute(
|
||||
'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');
|
||||
}
|
||||
|
||||
|
||||
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
|
||||
_log.i('Upgrading database from v$oldVersion to v$newVersion');
|
||||
if (oldVersion < 2) {
|
||||
@@ -95,20 +99,20 @@ class HistoryDatabase {
|
||||
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ==================== iOS Path Normalization ====================
|
||||
|
||||
|
||||
/// Pattern to match iOS container paths
|
||||
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
|
||||
static final _iosContainerPattern = RegExp(
|
||||
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
|
||||
/// Initialize and cache the current iOS container path
|
||||
Future<void> _initContainerPath() async {
|
||||
if (!Platform.isIOS || _currentContainerPath != null) return;
|
||||
|
||||
|
||||
try {
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Normalize iOS file path by replacing old container UUID with current one
|
||||
/// This fixes the issue where iOS changes container UUID after app updates
|
||||
String _normalizeIosPath(String? filePath) {
|
||||
if (filePath == null || filePath.isEmpty) return filePath ?? '';
|
||||
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
|
||||
|
||||
|
||||
// Check if path contains an iOS container path
|
||||
if (_iosContainerPattern.hasMatch(filePath)) {
|
||||
final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!);
|
||||
final normalized = filePath.replaceFirst(
|
||||
_iosContainerPattern,
|
||||
_currentContainerPath!,
|
||||
);
|
||||
if (normalized != filePath) {
|
||||
_log.d('Normalized iOS path: $filePath -> $normalized');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
|
||||
/// Migrate iOS paths in database to use current container UUID
|
||||
/// This is called once after app update if container changed
|
||||
Future<bool> migrateIosContainerPaths() async {
|
||||
if (!Platform.isIOS) return false;
|
||||
|
||||
|
||||
await _initContainerPath();
|
||||
if (_currentContainerPath == null) return false;
|
||||
|
||||
|
||||
final prefs = await _prefs;
|
||||
final lastContainer = prefs.getString('ios_last_container_path');
|
||||
|
||||
|
||||
if (lastContainer == _currentContainerPath) {
|
||||
_log.d('iOS container path unchanged, skipping migration');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
_log.i('iOS container changed: $lastContainer -> $_currentContainerPath');
|
||||
|
||||
|
||||
try {
|
||||
final db = await database;
|
||||
|
||||
|
||||
// Get all items with iOS paths
|
||||
final rows = await db.query('history', columns: ['id', 'file_path']);
|
||||
int updatedCount = 0;
|
||||
final batch = db.batch();
|
||||
|
||||
|
||||
for (final row in rows) {
|
||||
final id = row['id'] as String;
|
||||
final oldPath = row['file_path'] as String?;
|
||||
|
||||
|
||||
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
|
||||
final newPath = _normalizeIosPath(oldPath);
|
||||
if (newPath != oldPath) {
|
||||
@@ -184,14 +191,14 @@ class HistoryDatabase {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (updatedCount > 0) {
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
|
||||
// Save current container path
|
||||
await prefs.setString('ios_last_container_path', _currentContainerPath!);
|
||||
|
||||
|
||||
_log.i('iOS path migration complete: $updatedCount paths updated');
|
||||
return updatedCount > 0;
|
||||
} catch (e, stack) {
|
||||
@@ -199,32 +206,34 @@ class HistoryDatabase {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Migrate data from SharedPreferences to SQLite
|
||||
/// Returns true if migration was performed, false if already migrated
|
||||
Future<bool> migrateFromSharedPreferences() async {
|
||||
final prefs = await _prefs;
|
||||
final migrationKey = 'history_migrated_to_sqlite';
|
||||
|
||||
|
||||
if (prefs.getBool(migrationKey) == true) {
|
||||
_log.d('Already migrated to SQLite');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
final jsonStr = prefs.getString('download_history');
|
||||
if (jsonStr == null || jsonStr.isEmpty) {
|
||||
_log.d('No SharedPreferences history to migrate');
|
||||
await prefs.setBool(migrationKey, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
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 batch = db.batch();
|
||||
|
||||
|
||||
for (final json in jsonList) {
|
||||
final map = json as Map<String, dynamic>;
|
||||
batch.insert(
|
||||
@@ -233,20 +242,20 @@ class HistoryDatabase {
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
await batch.commit(noResult: true);
|
||||
|
||||
|
||||
// Mark as migrated but keep old data for safety
|
||||
await prefs.setBool(migrationKey, true);
|
||||
_log.i('Migration complete: ${jsonList.length} items');
|
||||
|
||||
|
||||
return true;
|
||||
} catch (e, stack) {
|
||||
_log.e('Migration failed: $e', e, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Convert JSON format (camelCase) to DB row (snake_case)
|
||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||
return {
|
||||
@@ -278,7 +287,7 @@ class HistoryDatabase {
|
||||
'copyright': json['copyright'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// Convert DB row (snake_case) to JSON format (camelCase)
|
||||
/// Also normalizes iOS paths if container UUID changed
|
||||
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
|
||||
@@ -311,9 +320,9 @@ class HistoryDatabase {
|
||||
'copyright': row['copyright'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ==================== CRUD Operations ====================
|
||||
|
||||
|
||||
/// Insert or update a history item
|
||||
Future<void> upsert(Map<String, dynamic> json) async {
|
||||
final db = await database;
|
||||
@@ -323,7 +332,7 @@ class HistoryDatabase {
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// Get all history items ordered by download date (newest first)
|
||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||
final db = await database;
|
||||
@@ -335,7 +344,7 @@ class HistoryDatabase {
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
|
||||
/// Get item by ID
|
||||
Future<Map<String, dynamic>?> getById(String id) async {
|
||||
final db = await database;
|
||||
@@ -348,7 +357,7 @@ class HistoryDatabase {
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
|
||||
/// Get item by Spotify ID - O(1) with index
|
||||
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
@@ -361,7 +370,7 @@ class HistoryDatabase {
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
|
||||
/// Get item by ISRC - O(1) with index
|
||||
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
|
||||
final db = await database;
|
||||
@@ -374,7 +383,7 @@ class HistoryDatabase {
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
|
||||
/// Check if spotify_id exists - O(1) with index
|
||||
Future<bool> existsBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
@@ -384,42 +393,42 @@ class HistoryDatabase {
|
||||
);
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
|
||||
/// Get all spotify_ids as Set for fast in-memory lookup
|
||||
Future<Set<String>> getAllSpotifyIds() async {
|
||||
final db = await database;
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
/// Delete by ID
|
||||
Future<void> deleteById(String id) async {
|
||||
final db = await database;
|
||||
await db.delete('history', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
|
||||
/// Delete by Spotify ID
|
||||
Future<void> deleteBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
|
||||
}
|
||||
|
||||
|
||||
/// Clear all history
|
||||
Future<void> clearAll() async {
|
||||
final db = await database;
|
||||
await db.delete('history');
|
||||
_log.i('Cleared all history');
|
||||
}
|
||||
|
||||
|
||||
/// Get total count
|
||||
Future<int> getCount() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
|
||||
/// Find existing item by spotify_id or isrc (for deduplication)
|
||||
Future<Map<String, dynamic>?> findExisting({
|
||||
String? spotifyId,
|
||||
@@ -428,7 +437,7 @@ class HistoryDatabase {
|
||||
if (spotifyId != null && spotifyId.isNotEmpty) {
|
||||
final bySpotify = await getBySpotifyId(spotifyId);
|
||||
if (bySpotify != null) return bySpotify;
|
||||
|
||||
|
||||
// Check for deezer: prefix matching
|
||||
if (spotifyId.startsWith('deezer:')) {
|
||||
final deezerId = spotifyId.substring(7);
|
||||
@@ -442,31 +451,63 @@ class HistoryDatabase {
|
||||
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
return await getByIsrc(isrc);
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Close database
|
||||
|
||||
/// Close database
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
_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
|
||||
/// Used to exclude downloaded files from local library scan
|
||||
Future<Set<String>> getAllFilePaths() async {
|
||||
final db = await database;
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
/// 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)
|
||||
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
|
||||
@@ -478,18 +519,24 @@ class HistoryDatabase {
|
||||
''');
|
||||
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
|
||||
/// Delete multiple entries by IDs
|
||||
Future<int> deleteByIds(List<String> ids) async {
|
||||
if (ids.isEmpty) return 0;
|
||||
|
||||
|
||||
final db = await database;
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
final count = await db.rawDelete(
|
||||
'DELETE FROM history WHERE id IN ($placeholders)',
|
||||
ids,
|
||||
);
|
||||
_log.i('Deleted $count orphaned entries');
|
||||
return count;
|
||||
var totalDeleted = 0;
|
||||
const chunkSize = 500;
|
||||
for (var i = 0; i < ids.length; i += chunkSize) {
|
||||
final end = (i + chunkSize < ids.length) ? i + chunkSize : ids.length;
|
||||
final chunk = ids.sublist(i, end);
|
||||
final placeholders = List.filled(chunk.length, '?').join(',');
|
||||
totalDeleted += await db.rawDelete(
|
||||
'DELETE FROM history WHERE id IN ($placeholders)',
|
||||
chunk,
|
||||
);
|
||||
}
|
||||
_log.i('Deleted $totalDeleted orphaned entries');
|
||||
return totalDeleted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,7 @@ class LibraryDatabase {
|
||||
}
|
||||
|
||||
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||
if (items.isEmpty) return;
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
|
||||
@@ -350,16 +351,46 @@ class LibraryDatabase {
|
||||
Future<int> cleanupMissingFiles() async {
|
||||
final db = await database;
|
||||
final rows = await db.query('library', columns: ['id', 'file_path']);
|
||||
|
||||
int removed = 0;
|
||||
for (final row in rows) {
|
||||
final filePath = row['file_path'] as String;
|
||||
if (!await fileExists(filePath)) {
|
||||
await db.delete('library', where: 'id = ?', whereArgs: [row['id']]);
|
||||
removed++;
|
||||
|
||||
final missingIds = <String>[];
|
||||
const checkChunkSize = 16;
|
||||
for (var i = 0; i < rows.length; i += checkChunkSize) {
|
||||
final end = (i + checkChunkSize < rows.length)
|
||||
? i + checkChunkSize
|
||||
: rows.length;
|
||||
final chunk = rows.sublist(i, end);
|
||||
final checks = await Future.wait<MapEntry<String, bool>>(
|
||||
chunk.map((row) async {
|
||||
final id = row['id'] as String;
|
||||
final filePath = row['file_path'] as String;
|
||||
return MapEntry(id, await fileExists(filePath));
|
||||
}),
|
||||
);
|
||||
for (final check in checks) {
|
||||
if (!check.value) {
|
||||
missingIds.add(check.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (missingIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var removed = 0;
|
||||
const deleteChunkSize = 500;
|
||||
for (var i = 0; i < missingIds.length; i += deleteChunkSize) {
|
||||
final end = (i + deleteChunkSize < missingIds.length)
|
||||
? i + deleteChunkSize
|
||||
: missingIds.length;
|
||||
final idChunk = missingIds.sublist(i, end);
|
||||
final placeholders = List.filled(idChunk.length, '?').join(',');
|
||||
removed += await db.rawDelete(
|
||||
'DELETE FROM library WHERE id IN ($placeholders)',
|
||||
idChunk,
|
||||
);
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
_log.i('Cleaned up $removed missing files from library');
|
||||
}
|
||||
@@ -440,14 +471,22 @@ class LibraryDatabase {
|
||||
Future<int> deleteByPaths(List<String> filePaths) async {
|
||||
if (filePaths.isEmpty) return 0;
|
||||
final db = await database;
|
||||
final placeholders = List.filled(filePaths.length, '?').join(',');
|
||||
final result = await db.rawDelete(
|
||||
'DELETE FROM library WHERE file_path IN ($placeholders)',
|
||||
filePaths,
|
||||
);
|
||||
if (result > 0) {
|
||||
_log.i('Deleted $result items from library');
|
||||
var totalDeleted = 0;
|
||||
const chunkSize = 500;
|
||||
for (var i = 0; i < filePaths.length; i += chunkSize) {
|
||||
final end = (i + chunkSize < filePaths.length)
|
||||
? i + chunkSize
|
||||
: filePaths.length;
|
||||
final chunk = filePaths.sublist(i, end);
|
||||
final placeholders = List.filled(chunk.length, '?').join(',');
|
||||
totalDeleted += await db.rawDelete(
|
||||
'DELETE FROM library WHERE file_path IN ($placeholders)',
|
||||
chunk,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
if (totalDeleted > 0) {
|
||||
_log.i('Deleted $totalDeleted items from library');
|
||||
}
|
||||
return totalDeleted;
|
||||
}
|
||||
}
|
||||
|
||||
+1134
-1253
File diff suppressed because it is too large
Load Diff
+81
-8
@@ -8,6 +8,27 @@ import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
const int _maxLogMessageLength = 500;
|
||||
const String _redactedValue = '[REDACTED]';
|
||||
|
||||
final RegExp _authorizationBearerPattern = RegExp(
|
||||
r'\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final RegExp _genericSensitiveKeyValuePattern = RegExp(
|
||||
r'\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final RegExp _sensitiveQueryPattern = RegExp(
|
||||
r'([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final RegExp _bearerTokenPattern = RegExp(
|
||||
r'\bBearer\s+[A-Za-z0-9._~+/\-]+=*',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
|
||||
if (value.length <= maxLength) {
|
||||
@@ -16,6 +37,33 @@ String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
|
||||
return '${value.substring(0, maxLength)}...[truncated]';
|
||||
}
|
||||
|
||||
String _redactSensitiveText(String value) {
|
||||
var redacted = value;
|
||||
|
||||
redacted = redacted.replaceAllMapped(_authorizationBearerPattern, (_) {
|
||||
return 'Authorization: Bearer $_redactedValue';
|
||||
});
|
||||
|
||||
redacted = redacted.replaceAllMapped(_genericSensitiveKeyValuePattern, (
|
||||
match,
|
||||
) {
|
||||
final key = match.group(1) ?? '';
|
||||
final delimiter = match.group(2) ?? '=';
|
||||
return '$key$delimiter$_redactedValue';
|
||||
});
|
||||
|
||||
redacted = redacted.replaceAllMapped(_sensitiveQueryPattern, (match) {
|
||||
final prefix = match.group(1) ?? '';
|
||||
return '$prefix$_redactedValue';
|
||||
});
|
||||
|
||||
redacted = redacted.replaceAllMapped(_bearerTokenPattern, (_) {
|
||||
return 'Bearer $_redactedValue';
|
||||
});
|
||||
|
||||
return redacted;
|
||||
}
|
||||
|
||||
class LogEntry {
|
||||
final DateTime timestamp;
|
||||
final String level;
|
||||
@@ -59,6 +107,7 @@ class LogBuffer extends ChangeNotifier {
|
||||
final Queue<LogEntry> _entries = Queue<LogEntry>();
|
||||
Timer? _goLogTimer;
|
||||
int _lastGoLogIndex = 0;
|
||||
bool _isFetchingGoLogs = false;
|
||||
|
||||
static bool _loggingEnabled = false;
|
||||
static bool get loggingEnabled => _loggingEnabled;
|
||||
@@ -79,9 +128,11 @@ class LogBuffer extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final sanitizedMessage = _truncateLogText(entry.message);
|
||||
final sanitizedMessage = _truncateLogText(
|
||||
_redactSensitiveText(entry.message),
|
||||
);
|
||||
final sanitizedError = entry.error != null
|
||||
? _truncateLogText(entry.error!)
|
||||
? _truncateLogText(_redactSensitiveText(entry.error!))
|
||||
: null;
|
||||
final sanitizedEntry =
|
||||
(sanitizedMessage == entry.message && sanitizedError == entry.error)
|
||||
@@ -105,13 +156,20 @@ class LogBuffer extends ChangeNotifier {
|
||||
void startGoLogPolling() {
|
||||
_goLogTimer?.cancel();
|
||||
_goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
|
||||
await _fetchGoLogs();
|
||||
if (_isFetchingGoLogs) return;
|
||||
_isFetchingGoLogs = true;
|
||||
try {
|
||||
await _fetchGoLogs();
|
||||
} finally {
|
||||
_isFetchingGoLogs = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void stopGoLogPolling() {
|
||||
_goLogTimer?.cancel();
|
||||
_goLogTimer = null;
|
||||
_isFetchingGoLogs = false;
|
||||
}
|
||||
|
||||
Future<void> _fetchGoLogs() async {
|
||||
@@ -119,10 +177,15 @@ class LogBuffer extends ChangeNotifier {
|
||||
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
||||
final logs = result['logs'] as List<dynamic>? ?? [];
|
||||
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
|
||||
final keepNonErrorLogs = _loggingEnabled;
|
||||
|
||||
for (final log in logs) {
|
||||
final timestamp = log['timestamp'] as String? ?? '';
|
||||
final level = log['level'] as String? ?? 'INFO';
|
||||
if (!keepNonErrorLogs && level != 'ERROR' && level != 'FATAL') {
|
||||
continue;
|
||||
}
|
||||
|
||||
final timestamp = log['timestamp'] as String? ?? '';
|
||||
final tag = log['tag'] as String? ?? 'Go';
|
||||
final message = log['message'] as String? ?? '';
|
||||
|
||||
@@ -211,7 +274,11 @@ class LogBuffer extends ChangeNotifier {
|
||||
buffer.writeln(
|
||||
'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})',
|
||||
);
|
||||
buffer.writeln('Device ID: ${android.id}');
|
||||
buffer.writeln('Build ID: ${android.id}');
|
||||
if (android.version.securityPatch != null &&
|
||||
android.version.securityPatch!.isNotEmpty) {
|
||||
buffer.writeln('Security Patch: ${android.version.securityPatch}');
|
||||
}
|
||||
buffer.writeln('Hardware: ${android.hardware}');
|
||||
buffer.writeln('Product: ${android.product}');
|
||||
buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}');
|
||||
@@ -308,12 +375,14 @@ class BufferedOutput extends LogOutput {
|
||||
void output(OutputEvent event) {
|
||||
if (kDebugMode) {
|
||||
for (final line in event.lines) {
|
||||
debugPrint(_truncateLogText(line));
|
||||
debugPrint(_truncateLogText(_redactSensitiveText(line)));
|
||||
}
|
||||
}
|
||||
|
||||
final level = _levelToString(event.level);
|
||||
final message = _truncateLogText(event.lines.join('\n'));
|
||||
final message = _truncateLogText(
|
||||
_redactSensitiveText(event.lines.join('\n')),
|
||||
);
|
||||
|
||||
LogBuffer().add(
|
||||
LogEntry(
|
||||
@@ -372,6 +441,10 @@ class AppLogger {
|
||||
}
|
||||
|
||||
void _addToBuffer(String level, String message, {String? error}) {
|
||||
if (!LogBuffer.loggingEnabled && level != 'ERROR' && level != 'FATAL') {
|
||||
return;
|
||||
}
|
||||
|
||||
LogBuffer().add(
|
||||
LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
@@ -412,7 +485,7 @@ class AppLogger {
|
||||
_addToBuffer('ERROR', message, error: error.toString());
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[$_tag] ERROR: ${_truncateLogText(message)} | ${_truncateLogText(error.toString())}',
|
||||
'[$_tag] ERROR: ${_truncateLogText(_redactSensitiveText(message))} | ${_truncateLogText(_redactSensitiveText(error.toString()))}',
|
||||
);
|
||||
if (stackTrace != null) {
|
||||
debugPrint(stackTrace.toString());
|
||||
|
||||
@@ -84,83 +84,6 @@ class _KofiPainter extends CustomPainter {
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class BmacIcon extends StatelessWidget {
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
const BmacIcon({super.key, this.size = 22, this.color = Colors.black87});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
size: Size(size, size),
|
||||
painter: _BmacPainter(color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BmacPainter extends CustomPainter {
|
||||
final Color color;
|
||||
_BmacPainter(this.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final s = size.width;
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
// Cup body (slightly tapered)
|
||||
final cup = Path()
|
||||
..moveTo(s * 0.15, s * 0.35)
|
||||
..lineTo(s * 0.20, s * 0.82)
|
||||
..quadraticBezierTo(s * 0.20, s * 0.90, s * 0.28, s * 0.90)
|
||||
..lineTo(s * 0.56, s * 0.90)
|
||||
..quadraticBezierTo(s * 0.64, s * 0.90, s * 0.64, s * 0.82)
|
||||
..lineTo(s * 0.69, s * 0.35)
|
||||
..close();
|
||||
canvas.drawPath(cup, paint);
|
||||
|
||||
// Cup rim
|
||||
final rim = RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(s * 0.10, s * 0.30, s * 0.64, s * 0.10),
|
||||
Radius.circular(s * 0.05),
|
||||
);
|
||||
canvas.drawRRect(rim, paint);
|
||||
|
||||
// Handle
|
||||
final handlePaint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = s * 0.07
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final handle = Path()
|
||||
..moveTo(s * 0.69, s * 0.42)
|
||||
..quadraticBezierTo(s * 0.90, s * 0.42, s * 0.90, s * 0.56)
|
||||
..quadraticBezierTo(s * 0.90, s * 0.70, s * 0.69, s * 0.70);
|
||||
canvas.drawPath(handle, handlePaint);
|
||||
|
||||
// Steam
|
||||
final steamPaint = Paint()
|
||||
..color = color.withValues(alpha: 0.5)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = s * 0.04
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final sx = s * (0.26 + i * 0.14);
|
||||
final steam = Path()
|
||||
..moveTo(sx, s * 0.26)
|
||||
..quadraticBezierTo(sx + s * 0.03, s * 0.18, sx, s * 0.10);
|
||||
canvas.drawPath(steam, steamPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class GitHubIcon extends StatelessWidget {
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
@@ -22,8 +22,7 @@ class BuiltInService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Default quality options for built-in services (Tidal, Qobuz, YouTube)
|
||||
/// Note: Amazon is fallback-only and not shown in picker
|
||||
/// Default quality options for built-in services (Tidal, Qobuz, Amazon, YouTube)
|
||||
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
|
||||
const _builtInServices = [
|
||||
BuiltInService(
|
||||
@@ -44,6 +43,17 @@ const _builtInServices = [
|
||||
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(
|
||||
id: 'youtube',
|
||||
label: 'YouTube',
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.6.0+77
|
||||
version: 3.6.5+79
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user