mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 11:48:00 +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
|
github: zarzet
|
||||||
ko_fi: zarzet
|
ko_fi: zarzet
|
||||||
buy_me_a_coffee: zarzet
|
|
||||||
|
|
||||||
|
|||||||
+138
@@ -1,5 +1,143 @@
|
|||||||
# Changelog
|
# 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
|
## [3.6.0] - 2026-02-09
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](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)
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
<div align="center">
|
<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?
|
### 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
|
## Disclaimer
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||||
private val safScanLock = Any()
|
private val safScanLock = Any()
|
||||||
|
private val safDirLock = Any()
|
||||||
private var safScanProgress = SafScanProgress()
|
private var safScanProgress = SafScanProgress()
|
||||||
@Volatile private var safScanCancel = false
|
@Volatile private var safScanCancel = false
|
||||||
@Volatile private var safScanActive = false
|
@Volatile private var safScanActive = false
|
||||||
@@ -299,27 +300,55 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
|
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||||
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
if (relativeDir.isBlank()) return ""
|
||||||
if (relativeDir.isBlank()) return current
|
return relativeDir
|
||||||
|
.split("/")
|
||||||
|
.map { sanitizeFilename(it) }
|
||||||
|
.filter { it.isNotBlank() && it != "." && it != ".." }
|
||||||
|
.joinToString("/")
|
||||||
|
}
|
||||||
|
|
||||||
val parts = relativeDir.split("/").filter { it.isNotBlank() }
|
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
||||||
for (part in parts) {
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
val existing = current.findFile(part)
|
if (safeRelativeDir.isBlank()) {
|
||||||
current = if (existing != null && existing.isDirectory) {
|
return DocumentFile.fromTreeUri(this, treeUri)
|
||||||
existing
|
}
|
||||||
} else {
|
|
||||||
current.createDirectory(part) ?: return null
|
// 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? {
|
private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
||||||
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
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) {
|
for (part in parts) {
|
||||||
val existing = current.findFile(part)
|
val existing = current.findFile(part)
|
||||||
if (existing == null || !existing.isDirectory) return null
|
if (existing == null || !existing.isDirectory) return null
|
||||||
@@ -359,14 +388,21 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
obj.put("relative_dir", "")
|
obj.put("relative_dir", "")
|
||||||
return obj.toString()
|
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 treeUri = Uri.parse(treeUriStr)
|
||||||
val targetDir = findDocumentDir(treeUri, relativeDir)
|
val targetDir = findDocumentDir(treeUri, safeRelativeDir)
|
||||||
if (targetDir != null) {
|
if (targetDir != null) {
|
||||||
val direct = targetDir.findFile(fileName)
|
val direct = targetDir.findFile(safeFileName)
|
||||||
if (direct != null && direct.isFile) {
|
if (direct != null && direct.isFile) {
|
||||||
obj.put("uri", direct.uri.toString())
|
obj.put("uri", direct.uri.toString())
|
||||||
obj.put("relative_dir", relativeDir)
|
obj.put("relative_dir", safeRelativeDir)
|
||||||
return obj.toString()
|
return obj.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,7 +428,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val childPath = if (path.isBlank()) childName else "$path/$childName"
|
val childPath = if (path.isBlank()) childName else "$path/$childName"
|
||||||
queue.add(child to childPath)
|
queue.add(child to childPath)
|
||||||
} else if (child.isFile) {
|
} else if (child.isFile) {
|
||||||
if (child.name == fileName) {
|
if (child.name == safeFileName) {
|
||||||
obj.put("uri", child.uri.toString())
|
obj.put("uri", child.uri.toString())
|
||||||
obj.put("relative_dir", path)
|
obj.put("relative_dir", path)
|
||||||
return obj.toString()
|
return obj.toString()
|
||||||
@@ -408,7 +444,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||||
val provided = req.optString("saf_file_name", "")
|
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 trackName = req.optString("track_name", "track")
|
||||||
val artistName = req.optString("artist_name", "")
|
val artistName = req.optString("artist_name", "")
|
||||||
@@ -599,7 +635,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val treeUri = Uri.parse(treeUriStr)
|
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 outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||||
val mimeType = mimeTypeForExt(outputExt)
|
val mimeType = mimeTypeForExt(outputExt)
|
||||||
val fileName = buildSafFileName(req, outputExt)
|
val fileName = buildSafFileName(req, outputExt)
|
||||||
@@ -1276,20 +1312,11 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
"downloadTrack" -> {
|
"downloadByStrategy" -> {
|
||||||
val requestJson = call.arguments as String
|
val requestJson = call.arguments as String
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
handleSafDownload(requestJson) { json ->
|
handleSafDownload(requestJson) { json ->
|
||||||
Gobackend.downloadTrack(json)
|
Gobackend.downloadByStrategy(json)
|
||||||
}
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"downloadWithFallback" -> {
|
|
||||||
val requestJson = call.arguments as String
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
handleSafDownload(requestJson) { json ->
|
|
||||||
Gobackend.downloadWithFallback(json)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
@@ -1465,11 +1492,12 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"safCreateFromPath" -> {
|
"safCreateFromPath" -> {
|
||||||
val treeUriStr = call.argument<String>("tree_uri") ?: ""
|
val treeUriStr = call.argument<String>("tree_uri") ?: ""
|
||||||
val relativeDir = call.argument<String>("relative_dir") ?: ""
|
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 mimeType = call.argument<String>("mime_type") ?: "application/octet-stream"
|
||||||
val srcPath = call.argument<String>("src_path") ?: ""
|
val srcPath = call.argument<String>("src_path") ?: ""
|
||||||
val createdUri = withContext(Dispatchers.IO) {
|
val createdUri = withContext(Dispatchers.IO) {
|
||||||
if (treeUriStr.isBlank()) return@withContext null
|
if (treeUriStr.isBlank()) return@withContext null
|
||||||
|
if (fileName.isBlank()) return@withContext null
|
||||||
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
|
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
|
||||||
val existing = dir.findFile(fileName)
|
val existing = dir.findFile(fileName)
|
||||||
val createdNew = existing == null
|
val createdNew = existing == null
|
||||||
@@ -1716,7 +1744,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
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 outputPath = call.argument<String>("output_path") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
@@ -2093,24 +2121,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
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" -> {
|
"enrichTrackWithExtension" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
val trackJson = call.argument<String>("track") ?: "{}"
|
val trackJson = call.argument<String>("track") ?: "{}"
|
||||||
|
|||||||
+231
-81
@@ -31,6 +31,8 @@ type AmazonDownloader struct {
|
|||||||
var (
|
var (
|
||||||
globalAmazonDownloader *AmazonDownloader
|
globalAmazonDownloader *AmazonDownloader
|
||||||
amazonDownloaderOnce sync.Once
|
amazonDownloaderOnce sync.Once
|
||||||
|
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
|
||||||
|
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// AfkarXYZResponse is the response from AfkarXYZ API
|
// AfkarXYZResponse is the response from AfkarXYZ API
|
||||||
@@ -43,6 +45,12 @@ type AfkarXYZResponse struct {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin}
|
||||||
|
type AmazonStreamResponse struct {
|
||||||
|
StreamURL string `json:"streamUrl"`
|
||||||
|
DecryptionKey string `json:"decryptionKey"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
amazonDownloaderOnce.Do(func() {
|
amazonDownloaderOnce.Do(func() {
|
||||||
globalAmazonDownloader = &AmazonDownloader{
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
@@ -52,10 +60,9 @@ func NewAmazonDownloader() *AmazonDownloader {
|
|||||||
return globalAmazonDownloader
|
return globalAmazonDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks
|
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
|
||||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) {
|
// Returns downloadURL, suggested fileName, optional decryptionKey.
|
||||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
@@ -64,66 +71,184 @@ func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, st
|
|||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL)
|
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return downloadURL, fileName, nil
|
return downloadURL, fileName, decryptionKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
errStr := err.Error()
|
errStr := strings.ToLower(err.Error())
|
||||||
|
|
||||||
// Check if error is retryable
|
// Check if error is retryable
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||||
strings.Contains(errStr, "connection reset") ||
|
strings.Contains(errStr, "connection reset") ||
|
||||||
strings.Contains(errStr, "connection refused") ||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
strings.Contains(errStr, "EOF") ||
|
strings.Contains(errStr, "eof") ||
|
||||||
strings.Contains(errStr, "status 5") ||
|
strings.Contains(errStr, "status 5") ||
|
||||||
strings.Contains(errStr, "status 429")
|
strings.Contains(errStr, "status 429") ||
|
||||||
|
strings.Contains(errStr, "http 429")
|
||||||
|
|
||||||
if !isRetryable {
|
if !isRetryable {
|
||||||
return "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doAfkarXYZRequest performs a single request to AfkarXYZ API
|
func normalizeAmazonASIN(candidate string) string {
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) {
|
trimmed := strings.TrimSpace(candidate)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded, err := url.QueryUnescape(trimmed); err == nil {
|
||||||
|
trimmed = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed = strings.ToUpper(trimmed)
|
||||||
|
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
|
||||||
|
trimmed = trimmed[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if amazonASINRegex.MatchString(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAmazonASIN(amazonURL string) string {
|
||||||
|
raw := strings.TrimSpace(amazonURL)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err == nil {
|
||||||
|
query := parsed.Query()
|
||||||
|
|
||||||
|
// Prefer track-level ASIN when URL also contains albumAsin.
|
||||||
|
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
|
||||||
|
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
|
||||||
|
return asin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.Trim(parsed.Path, "/")
|
||||||
|
if path != "" {
|
||||||
|
segments := strings.Split(path, "/")
|
||||||
|
|
||||||
|
for i := 0; i < len(segments)-1; i++ {
|
||||||
|
segment := strings.ToLower(strings.TrimSpace(segments[i]))
|
||||||
|
if segment == "track" || segment == "tracks" {
|
||||||
|
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
|
||||||
|
return asin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
|
||||||
|
return asin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
|
||||||
|
return normalizeAmazonASIN(match)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doAfkarXYZRequest performs a single request to Amazon API.
|
||||||
|
// It tries new endpoint first, then falls back to legacy /convert endpoint.
|
||||||
|
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
|
||||||
|
asin := extractAmazonASIN(amazonURL)
|
||||||
|
if asin != "" {
|
||||||
|
GoLog("[Amazon] Using ASIN: %s\n", asin)
|
||||||
|
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
|
||||||
|
if err == nil {
|
||||||
|
return downloadURL, fileName, decryptKey, nil
|
||||||
|
}
|
||||||
|
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
|
||||||
|
}
|
||||||
|
return a.doAfkarXYZRequestLegacy(amazonURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
return "", "", "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp AmazonStreamResponse
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(apiResp.StreamURL) == "" {
|
||||||
|
return "", "", "", fmt.Errorf("Amazon API returned empty stream URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := asin + ".m4a"
|
||||||
|
return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
|
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
|
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to read response: %w", err)
|
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiResp AfkarXYZResponse
|
var apiResp AfkarXYZResponse
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
return "", "", fmt.Errorf("failed to decode response: %w", err)
|
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !apiResp.Success || apiResp.Data.DirectLink == "" {
|
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
|
||||||
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
|
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
|
||||||
}
|
}
|
||||||
|
|
||||||
fileName := apiResp.Data.FileName
|
fileName := apiResp.Data.FileName
|
||||||
@@ -134,19 +259,22 @@ func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, err
|
|||||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||||
fileName = reg.ReplaceAllString(fileName, "")
|
fileName = reg.ReplaceAllString(fileName, "")
|
||||||
|
|
||||||
return apiResp.Data.DirectLink, fileName, nil
|
return apiResp.Data.DirectLink, fileName, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
|
||||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||||
|
|
||||||
downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL)
|
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decryptionKey != "" {
|
||||||
|
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
|
||||||
|
}
|
||||||
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
||||||
return downloadURL, fileName, nil
|
return downloadURL, fileName, decryptionKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||||
@@ -233,17 +361,18 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD
|
|||||||
|
|
||||||
// AmazonDownloadResult contains download result with quality info
|
// AmazonDownloadResult contains download result with quality info
|
||||||
type AmazonDownloadResult struct {
|
type AmazonDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
Album string
|
Album string
|
||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
LyricsLRC string
|
LyricsLRC string
|
||||||
|
DecryptionKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
@@ -299,7 +428,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download using AfkarXYZ API
|
// Download using AfkarXYZ API
|
||||||
downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
||||||
}
|
}
|
||||||
@@ -321,7 +450,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
filename = sanitizeFilename(filename) + ".flac"
|
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
|
||||||
|
if outputExt == "" {
|
||||||
|
outputExt = ".flac"
|
||||||
|
}
|
||||||
|
filename = sanitizeFilename(filename) + outputExt
|
||||||
outputPath = filepath.Join(req.OutputDir, filename)
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
@@ -352,6 +485,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actualOutputPath := outputPath
|
||||||
|
needsDecryption := strings.TrimSpace(decryptionKey) != ""
|
||||||
|
if needsDecryption {
|
||||||
|
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
@@ -360,7 +499,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
|
||||||
actualTrackNum := req.TrackNumber
|
actualTrackNum := req.TrackNumber
|
||||||
actualDiscNum := req.DiscNumber
|
actualDiscNum := req.DiscNumber
|
||||||
actualDate := req.ReleaseDate
|
actualDate := req.ReleaseDate
|
||||||
@@ -368,25 +506,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
actualTitle := req.TrackName
|
actualTitle := req.TrackName
|
||||||
actualArtist := req.ArtistName
|
actualArtist := req.ArtistName
|
||||||
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
if !needsDecryption {
|
||||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
existingMeta, metaErr := ReadMetadata(actualOutputPath)
|
||||||
actualTrackNum = existingMeta.TrackNumber
|
if metaErr == nil && existingMeta != nil {
|
||||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||||
|
actualTrackNum = existingMeta.TrackNumber
|
||||||
|
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||||
|
}
|
||||||
|
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||||
|
actualDiscNum = existingMeta.DiscNumber
|
||||||
|
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||||
|
}
|
||||||
|
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
||||||
|
actualDate = existingMeta.Date
|
||||||
|
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
||||||
|
}
|
||||||
|
if existingMeta.Album != "" && req.AlbumName == "" {
|
||||||
|
actualAlbum = existingMeta.Album
|
||||||
|
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
||||||
|
}
|
||||||
|
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
||||||
|
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
||||||
}
|
}
|
||||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
|
||||||
actualDiscNum = existingMeta.DiscNumber
|
|
||||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
|
||||||
}
|
|
||||||
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
|
||||||
actualDate = existingMeta.Date
|
|
||||||
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
|
||||||
}
|
|
||||||
if existingMeta.Album != "" && req.AlbumName == "" {
|
|
||||||
actualAlbum = existingMeta.Album
|
|
||||||
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
|
||||||
}
|
|
||||||
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
|
||||||
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
@@ -409,7 +550,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
} else {
|
} else {
|
||||||
existingCover, coverErr := ExtractCoverArt(outputPath)
|
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
|
||||||
if coverErr == nil && len(existingCover) > 0 {
|
if coverErr == nil && len(existingCover) > 0 {
|
||||||
coverData = existingCover
|
coverData = existingCover
|
||||||
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
||||||
@@ -418,11 +559,16 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isSafOutput {
|
if isSafOutput || needsDecryption {
|
||||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||||
} else {
|
} else {
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
|
||||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
if isFlacOutput {
|
||||||
|
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||||
|
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
@@ -433,20 +579,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
if lyricsMode == "external" || lyricsMode == "both" {
|
||||||
GoLog("[Amazon] Saving external LRC file...\n")
|
GoLog("[Amazon] Saving external LRC file...\n")
|
||||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
|
||||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||||
}
|
}
|
||||||
|
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
|
||||||
|
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
|
||||||
}
|
}
|
||||||
} else if req.EmbedLyrics {
|
} else if req.EmbedLyrics {
|
||||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||||
@@ -456,17 +604,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||||
|
|
||||||
quality := AudioQuality{}
|
quality := AudioQuality{}
|
||||||
if isSafOutput {
|
if isSafOutput || needsDecryption {
|
||||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
||||||
} else {
|
} else {
|
||||||
quality, err = GetAudioQuality(outputPath)
|
quality, err = GetAudioQuality(actualOutputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
|
||||||
if metaReadErr == nil && finalMeta != nil {
|
if metaReadErr == nil && finalMeta != nil {
|
||||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||||
@@ -478,9 +626,10 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to ISRC index for fast duplicate checking
|
// Add to ISRC index for fast duplicate checking.
|
||||||
if !isSafOutput {
|
// When decryption is pending in Flutter, postpone indexing until final file is settled.
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
if !isSafOutput && !needsDecryption {
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
bitDepth := 0
|
bitDepth := 0
|
||||||
@@ -496,16 +645,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return AmazonDownloadResult{
|
return AmazonDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: bitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
ReleaseDate: req.ReleaseDate,
|
ReleaseDate: req.ReleaseDate,
|
||||||
TrackNumber: actualTrackNum,
|
TrackNumber: actualTrackNum,
|
||||||
DiscNumber: actualDiscNum,
|
DiscNumber: actualDiscNum,
|
||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
LyricsLRC: lyricsLRC,
|
LyricsLRC: lyricsLRC,
|
||||||
|
DecryptionKey: decryptionKey,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractAmazonASIN(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "prefers trackAsin over albumAsin",
|
||||||
|
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
|
||||||
|
want: "B0TRACK456",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extract from tracks path",
|
||||||
|
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
|
||||||
|
want: "B0CYQHGWZJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extract from plain query asin",
|
||||||
|
url: "https://example.com/?asin=B0CYQHGWZJ",
|
||||||
|
want: "B0CYQHGWZJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback regex",
|
||||||
|
url: "https://example.com/path/B0CYQHGWZJ",
|
||||||
|
want: "B0CYQHGWZJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid url",
|
||||||
|
url: "https://music.amazon.com/tracks/not-valid",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := extractAmazonASIN(tt.url)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ type AudioMetadata struct {
|
|||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
|
Lyrics string
|
||||||
Label string
|
Label string
|
||||||
Copyright string
|
Copyright string
|
||||||
Composer string
|
Composer string
|
||||||
@@ -181,6 +182,15 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
|||||||
metadata.Label = value
|
metadata.Label = value
|
||||||
case "TCR":
|
case "TCR":
|
||||||
metadata.Copyright = value
|
metadata.Copyright = value
|
||||||
|
case "ULT":
|
||||||
|
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = v
|
||||||
|
}
|
||||||
|
case "TXX":
|
||||||
|
desc, userValue := extractUserTextFrame(frameData)
|
||||||
|
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = userValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pos += 6 + frameSize
|
pos += 6 + frameSize
|
||||||
@@ -297,6 +307,15 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
|||||||
if v := extractCommentFrame(frameData); v != "" {
|
if v := extractCommentFrame(frameData); v != "" {
|
||||||
metadata.Comment = v
|
metadata.Comment = v
|
||||||
}
|
}
|
||||||
|
case "USLT":
|
||||||
|
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = v
|
||||||
|
}
|
||||||
|
case "TXXX":
|
||||||
|
desc, userValue := extractUserTextFrame(frameData)
|
||||||
|
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = userValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pos += 10 + frameSize
|
pos += 10 + frameSize
|
||||||
@@ -399,6 +418,98 @@ func extractCommentFrame(data []byte) string {
|
|||||||
return extractTextFrame(framed)
|
return extractTextFrame(framed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractLyricsFrame parses ID3 unsynchronized lyrics frames (USLT/ULT).
|
||||||
|
// Format: encoding(1) + language(3) + description(null-terminated) + lyrics text.
|
||||||
|
func extractLyricsFrame(data []byte) string {
|
||||||
|
if len(data) < 5 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
encoding := data[0]
|
||||||
|
rest := data[4:] // skip 3-byte language code
|
||||||
|
|
||||||
|
var text []byte
|
||||||
|
switch encoding {
|
||||||
|
case 1, 2: // UTF-16 variants use double-null terminator
|
||||||
|
for i := 0; i+1 < len(rest); i += 2 {
|
||||||
|
if rest[i] == 0 && rest[i+1] == 0 {
|
||||||
|
text = rest[i+2:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: // ISO-8859-1 or UTF-8
|
||||||
|
idx := bytes.IndexByte(rest, 0)
|
||||||
|
if idx >= 0 && idx+1 < len(rest) {
|
||||||
|
text = rest[idx+1:]
|
||||||
|
} else {
|
||||||
|
text = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(text) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
framed := make([]byte, 1+len(text))
|
||||||
|
framed[0] = encoding
|
||||||
|
copy(framed[1:], text)
|
||||||
|
return extractTextFrame(framed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
|
||||||
|
// encoding(1) + description + separator + value.
|
||||||
|
func extractUserTextFrame(data []byte) (string, string) {
|
||||||
|
if len(data) < 2 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
encoding := data[0]
|
||||||
|
payload := data[1:]
|
||||||
|
|
||||||
|
var descRaw, valueRaw []byte
|
||||||
|
switch encoding {
|
||||||
|
case 1, 2: // UTF-16 variants
|
||||||
|
for i := 0; i+1 < len(payload); i += 2 {
|
||||||
|
if payload[i] == 0 && payload[i+1] == 0 {
|
||||||
|
descRaw = payload[:i]
|
||||||
|
valueRaw = payload[i+2:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: // ISO-8859-1 or UTF-8
|
||||||
|
idx := bytes.IndexByte(payload, 0)
|
||||||
|
if idx >= 0 {
|
||||||
|
descRaw = payload[:idx]
|
||||||
|
if idx+1 <= len(payload) {
|
||||||
|
valueRaw = payload[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(valueRaw) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
descFramed := make([]byte, 1+len(descRaw))
|
||||||
|
descFramed[0] = encoding
|
||||||
|
copy(descFramed[1:], descRaw)
|
||||||
|
|
||||||
|
valueFramed := make([]byte, 1+len(valueRaw))
|
||||||
|
valueFramed[0] = encoding
|
||||||
|
copy(valueFramed[1:], valueRaw)
|
||||||
|
|
||||||
|
return strings.TrimSpace(extractTextFrame(descFramed)), strings.TrimSpace(extractTextFrame(valueFramed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLyricsDescription(description string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(description)) {
|
||||||
|
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func decodeUTF16(data []byte) string {
|
func decodeUTF16(data []byte) string {
|
||||||
if len(data) < 2 {
|
if len(data) < 2 {
|
||||||
return ""
|
return ""
|
||||||
@@ -800,9 +911,16 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if commentLen > 10000 {
|
remaining := uint32(reader.Len())
|
||||||
|
if commentLen > remaining {
|
||||||
break
|
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)
|
comment := make([]byte, commentLen)
|
||||||
if _, err := reader.Read(comment); err != nil {
|
if _, err := reader.Read(comment); err != nil {
|
||||||
@@ -843,6 +961,10 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
metadata.Composer = value
|
metadata.Composer = value
|
||||||
case "COMMENT", "DESCRIPTION":
|
case "COMMENT", "DESCRIPTION":
|
||||||
metadata.Comment = value
|
metadata.Comment = value
|
||||||
|
case "LYRICS", "UNSYNCEDLYRICS":
|
||||||
|
if metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = value
|
||||||
|
}
|
||||||
case "ORGANIZATION", "LABEL", "PUBLISHER":
|
case "ORGANIZATION", "LABEL", "PUBLISHER":
|
||||||
metadata.Label = value
|
metadata.Label = value
|
||||||
case "COPYRIGHT":
|
case "COPYRIGHT":
|
||||||
|
|||||||
+129
-15
@@ -28,15 +28,23 @@ const (
|
|||||||
deezerAPITimeoutMobile = 25 * time.Second
|
deezerAPITimeoutMobile = 25 * time.Second
|
||||||
deezerMaxRetries = 2
|
deezerMaxRetries = 2
|
||||||
deezerRetryDelay = 500 * time.Millisecond
|
deezerRetryDelay = 500 * time.Millisecond
|
||||||
|
|
||||||
|
deezerMaxSearchCacheEntries = 300
|
||||||
|
deezerMaxAlbumCacheEntries = 200
|
||||||
|
deezerMaxArtistCacheEntries = 200
|
||||||
|
deezerMaxISRCCacheEntries = 4000
|
||||||
|
deezerCacheCleanupInterval = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type DeezerClient struct {
|
type DeezerClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
searchCache map[string]*cacheEntry
|
searchCache map[string]*cacheEntry
|
||||||
albumCache map[string]*cacheEntry
|
albumCache map[string]*cacheEntry
|
||||||
artistCache map[string]*cacheEntry
|
artistCache map[string]*cacheEntry
|
||||||
isrcCache map[string]string
|
isrcCache map[string]string
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
|
lastCacheCleanup time.Time
|
||||||
|
cacheCleanupInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -47,16 +55,111 @@ var (
|
|||||||
func GetDeezerClient() *DeezerClient {
|
func GetDeezerClient() *DeezerClient {
|
||||||
deezerClientOnce.Do(func() {
|
deezerClientOnce.Do(func() {
|
||||||
deezerClient = &DeezerClient{
|
deezerClient = &DeezerClient{
|
||||||
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
|
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
|
||||||
searchCache: make(map[string]*cacheEntry),
|
searchCache: make(map[string]*cacheEntry),
|
||||||
albumCache: make(map[string]*cacheEntry),
|
albumCache: make(map[string]*cacheEntry),
|
||||||
artistCache: make(map[string]*cacheEntry),
|
artistCache: make(map[string]*cacheEntry),
|
||||||
isrcCache: make(map[string]string),
|
isrcCache: make(map[string]string),
|
||||||
|
cacheCleanupInterval: deezerCacheCleanupInterval,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return deezerClient
|
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 {
|
type deezerTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
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))
|
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()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -555,10 +660,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.albumCache[albumID] = &cacheEntry{
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -638,10 +745,12 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.artistCache[artistID] = &cacheEntry{
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -807,6 +916,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
for trackIDStr, isrc := range directISRCs {
|
for trackIDStr, isrc := range directISRCs {
|
||||||
c.isrcCache[trackIDStr] = isrc
|
c.isrcCache[trackIDStr] = isrc
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(time.Now())
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,6 +951,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
||||||
|
c.maybeCleanupCachesLocked(time.Now())
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
}(track)
|
}(track)
|
||||||
}
|
}
|
||||||
@@ -864,6 +975,7 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
|||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.isrcCache[trackID] = fullTrack.ISRC
|
c.isrcCache[trackID] = fullTrack.ISRC
|
||||||
|
c.maybeCleanupCachesLocked(time.Now())
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return fullTrack.ISRC, nil
|
return fullTrack.ISRC, nil
|
||||||
@@ -946,10 +1058,12 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||||
|
|||||||
+337
-118
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -47,10 +48,30 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
|||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if shouldTrySpotFetchFallback(err) {
|
||||||
|
data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
||||||
|
if apiErr == nil {
|
||||||
|
jsonBytes, marshalErr := json.Marshal(data)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return "", marshalErr
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if shouldTrySpotFetchFallback(err) {
|
||||||
|
fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
||||||
|
if apiErr == nil {
|
||||||
|
jsonBytes, marshalErr := json.Marshal(fallbackData)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return "", marshalErr
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +171,8 @@ type DownloadRequest struct {
|
|||||||
QobuzID string `json:"qobuz_id,omitempty"`
|
QobuzID string `json:"qobuz_id,omitempty"`
|
||||||
DeezerID string `json:"deezer_id,omitempty"`
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||||
|
UseExtensions bool `json:"use_extensions,omitempty"`
|
||||||
|
UseFallback bool `json:"use_fallback,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResponse struct {
|
type DownloadResponse struct {
|
||||||
@@ -176,20 +199,90 @@ type DownloadResponse struct {
|
|||||||
Copyright string `json:"copyright,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||||
|
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResult struct {
|
type DownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
Album string
|
Album string
|
||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
LyricsLRC string
|
LyricsLRC string
|
||||||
|
DecryptionKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDownloadSuccessResponse(
|
||||||
|
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) {
|
func DownloadTrack(requestJSON string) (string, error) {
|
||||||
@@ -254,17 +347,18 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
Title: amazonResult.Title,
|
Title: amazonResult.Title,
|
||||||
Artist: amazonResult.Artist,
|
Artist: amazonResult.Artist,
|
||||||
Album: amazonResult.Album,
|
Album: amazonResult.Album,
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
LyricsLRC: amazonResult.LyricsLRC,
|
LyricsLRC: amazonResult.LyricsLRC,
|
||||||
|
DecryptionKey: amazonResult.DecryptionKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
@@ -301,22 +395,14 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
}
|
}
|
||||||
resp := DownloadResponse{
|
resp := buildDownloadSuccessResponse(
|
||||||
Success: true,
|
req,
|
||||||
Message: "File already exists",
|
result,
|
||||||
FilePath: actualPath,
|
req.Service,
|
||||||
AlreadyExists: true,
|
"File already exists",
|
||||||
ActualBitDepth: result.BitDepth,
|
actualPath,
|
||||||
ActualSampleRate: result.SampleRate,
|
true,
|
||||||
Service: req.Service,
|
)
|
||||||
Title: result.Title,
|
|
||||||
Artist: result.Artist,
|
|
||||||
Album: result.Album,
|
|
||||||
ReleaseDate: result.ReleaseDate,
|
|
||||||
TrackNumber: result.TrackNumber,
|
|
||||||
DiscNumber: result.DiscNumber,
|
|
||||||
ISRC: result.ISRC,
|
|
||||||
}
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
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)
|
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := buildDownloadSuccessResponse(
|
||||||
Success: true,
|
req,
|
||||||
Message: "Download complete",
|
result,
|
||||||
FilePath: result.FilePath,
|
req.Service,
|
||||||
ActualBitDepth: result.BitDepth,
|
"Download complete",
|
||||||
ActualSampleRate: result.SampleRate,
|
result.FilePath,
|
||||||
Service: req.Service,
|
false,
|
||||||
Title: result.Title,
|
)
|
||||||
Artist: result.Artist,
|
|
||||||
Album: result.Album,
|
|
||||||
ReleaseDate: result.ReleaseDate,
|
|
||||||
TrackNumber: result.TrackNumber,
|
|
||||||
DiscNumber: result.DiscNumber,
|
|
||||||
ISRC: result.ISRC,
|
|
||||||
LyricsLRC: result.LyricsLRC,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
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) {
|
func DownloadWithFallback(requestJSON string) (string, error) {
|
||||||
var req DownloadRequest
|
var req DownloadRequest
|
||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
@@ -440,17 +559,18 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
Title: amazonResult.Title,
|
Title: amazonResult.Title,
|
||||||
Artist: amazonResult.Artist,
|
Artist: amazonResult.Artist,
|
||||||
Album: amazonResult.Album,
|
Album: amazonResult.Album,
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
LyricsLRC: amazonResult.LyricsLRC,
|
LyricsLRC: amazonResult.LyricsLRC,
|
||||||
|
DecryptionKey: amazonResult.DecryptionKey,
|
||||||
}
|
}
|
||||||
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
||||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||||
@@ -470,23 +590,14 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
}
|
}
|
||||||
resp := DownloadResponse{
|
resp := buildDownloadSuccessResponse(
|
||||||
Success: true,
|
req,
|
||||||
Message: "File already exists",
|
result,
|
||||||
FilePath: actualPath,
|
service,
|
||||||
AlreadyExists: true,
|
"File already exists",
|
||||||
ActualBitDepth: result.BitDepth,
|
actualPath,
|
||||||
ActualSampleRate: result.SampleRate,
|
true,
|
||||||
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,
|
|
||||||
}
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
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)
|
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := buildDownloadSuccessResponse(
|
||||||
Success: true,
|
req,
|
||||||
Message: "Downloaded from " + service,
|
result,
|
||||||
FilePath: result.FilePath,
|
service,
|
||||||
ActualBitDepth: result.BitDepth,
|
"Downloaded from "+service,
|
||||||
ActualSampleRate: result.SampleRate,
|
result.FilePath,
|
||||||
Service: service,
|
false,
|
||||||
Title: result.Title,
|
)
|
||||||
Artist: result.Artist,
|
|
||||||
Album: result.Album,
|
|
||||||
ReleaseDate: result.ReleaseDate,
|
|
||||||
TrackNumber: result.TrackNumber,
|
|
||||||
DiscNumber: result.DiscNumber,
|
|
||||||
ISRC: result.ISRC,
|
|
||||||
LyricsLRC: result.LyricsLRC,
|
|
||||||
}
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
@@ -622,6 +725,7 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["track_number"] = meta.TrackNumber
|
result["track_number"] = meta.TrackNumber
|
||||||
result["disc_number"] = meta.DiscNumber
|
result["disc_number"] = meta.DiscNumber
|
||||||
result["isrc"] = meta.ISRC
|
result["isrc"] = meta.ISRC
|
||||||
|
result["lyrics"] = meta.Lyrics
|
||||||
result["genre"] = meta.Genre
|
result["genre"] = meta.Genre
|
||||||
result["composer"] = meta.Composer
|
result["composer"] = meta.Composer
|
||||||
result["comment"] = meta.Comment
|
result["comment"] = meta.Comment
|
||||||
@@ -646,6 +750,7 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["track_number"] = meta.TrackNumber
|
result["track_number"] = meta.TrackNumber
|
||||||
result["disc_number"] = meta.DiscNumber
|
result["disc_number"] = meta.DiscNumber
|
||||||
result["isrc"] = meta.ISRC
|
result["isrc"] = meta.ISRC
|
||||||
|
result["lyrics"] = meta.Lyrics
|
||||||
result["genre"] = meta.Genre
|
result["genre"] = meta.Genre
|
||||||
result["composer"] = meta.Composer
|
result["composer"] = meta.Composer
|
||||||
result["comment"] = meta.Comment
|
result["comment"] = meta.Comment
|
||||||
@@ -678,6 +783,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|||||||
|
|
||||||
lower := strings.ToLower(filePath)
|
lower := strings.ToLower(filePath)
|
||||||
isFlac := strings.HasSuffix(lower, ".flac")
|
isFlac := strings.HasSuffix(lower, ".flac")
|
||||||
|
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||||
|
|
||||||
if isFlac {
|
if isFlac {
|
||||||
trackNum := 0
|
trackNum := 0
|
||||||
@@ -705,7 +811,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|||||||
Comment: fields["comment"],
|
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)
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
var spotifyErr error
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
||||||
|
spotifyErr = err
|
||||||
} else {
|
} else {
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1114,28 +1223,81 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
errStr := strings.ToLower(err.Error())
|
spotifyErr = err
|
||||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
if !shouldTrySpotFetchFallback(err) {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
||||||
|
if apiErr == nil {
|
||||||
|
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
|
||||||
|
jsonBytes, err := json.Marshal(spotFetchData)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
GoLog("[Fallback] SpotFetch API fallback failed: %v\n", apiErr)
|
||||||
|
|
||||||
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
if spotifyErr != nil {
|
||||||
|
return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
GoLog("[Fallback] Trying Deezer conversion fallback for %s...\n", parsed.Type)
|
||||||
|
|
||||||
if parsed.Type == "track" || parsed.Type == "album" {
|
if parsed.Type == "track" || parsed.Type == "album" {
|
||||||
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Type == "artist" {
|
if parsed.Type == "artist" {
|
||||||
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
if spotifyErr != nil {
|
||||||
|
return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
if spotifyErr != nil {
|
||||||
|
return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldTrySpotFetchFallback(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrNoSpotifyCredentials) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
indicators := []string{
|
||||||
|
"429",
|
||||||
|
"rate",
|
||||||
|
"limit",
|
||||||
|
"403",
|
||||||
|
"forbidden",
|
||||||
|
"401",
|
||||||
|
"unauthorized",
|
||||||
|
"timeout",
|
||||||
|
"connection",
|
||||||
|
"spotify error",
|
||||||
|
"access token",
|
||||||
|
"client token",
|
||||||
|
"eof",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, indicator := range indicators {
|
||||||
|
if strings.Contains(errStr, indicator) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||||
@@ -1266,6 +1428,10 @@ func DownloadFromYouTube(requestJSON string) (string, error) {
|
|||||||
DiscNumber: youtubeResult.DiscNumber,
|
DiscNumber: youtubeResult.DiscNumber,
|
||||||
ISRC: youtubeResult.ISRC,
|
ISRC: youtubeResult.ISRC,
|
||||||
LyricsLRC: youtubeResult.LyricsLRC,
|
LyricsLRC: youtubeResult.LyricsLRC,
|
||||||
|
CoverURL: req.CoverURL,
|
||||||
|
Genre: req.Genre,
|
||||||
|
Label: req.Label,
|
||||||
|
Copyright: req.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
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",
|
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)
|
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
|
// Download cover art to temp file
|
||||||
var coverTempPath string
|
var coverTempPath string
|
||||||
|
var coverDataBytes []byte
|
||||||
if req.CoverURL != "" {
|
if req.CoverURL != "" {
|
||||||
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
|
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
|
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
|
coverDataBytes = coverData
|
||||||
if err == nil {
|
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
|
||||||
coverTempPath = tmpFile.Name()
|
// MP3/Opus requires a real image file path for Dart FFmpeg.
|
||||||
tmpFile.Write(coverData)
|
// FLAC uses in-memory embed and does not require temp files.
|
||||||
tmpFile.Close()
|
if !isFlac {
|
||||||
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
|
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)
|
// Build enriched metadata response for Dart (includes online search results)
|
||||||
enrichedMeta := map[string]interface{}{
|
enrichedMeta := map[string]interface{}{
|
||||||
"track_name": req.TrackName,
|
"track_name": req.TrackName,
|
||||||
@@ -1608,8 +1799,24 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
Lyrics: lyricsLRC,
|
Lyrics: lyricsLRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadata(req.FilePath, metadata, coverTempPath); err != nil {
|
if len(coverDataBytes) > 0 {
|
||||||
return "", fmt.Errorf("failed to embed metadata: %w", err)
|
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")
|
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
|
||||||
@@ -2699,14 +2906,26 @@ func GetStoreCategoriesJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
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) {
|
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||||
store := GetExtensionStore()
|
store := GetExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return "", fmt.Errorf("extension store not initialized")
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
destPath := fmt.Sprintf("%s/%s.spotiflac-ext", destDir, extensionID)
|
destPath, err := buildStoreExtensionDestPath(destDir, extensionID)
|
||||||
err := store.DownloadExtension(extensionID, destPath)
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
err = store.DownloadExtension(extensionID, destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1082,16 +1082,18 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
Title: amazonResult.Title,
|
Title: amazonResult.Title,
|
||||||
Artist: amazonResult.Artist,
|
Artist: amazonResult.Artist,
|
||||||
Album: amazonResult.Album,
|
Album: amazonResult.Album,
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
|
LyricsLRC: amazonResult.LyricsLRC,
|
||||||
|
DecryptionKey: amazonResult.DecryptionKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
@@ -1119,6 +1121,8 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
|
LyricsLRC: result.LyricsLRC,
|
||||||
|
DecryptionKey: result.DecryptionKey,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,16 +1168,30 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
optionsJSON, _ := json.Marshal(options)
|
if options == nil {
|
||||||
|
options = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
// Avoid embedding user input directly into JS source. Some inputs can trigger
|
||||||
|
// parser/runtime edge cases on specific devices/Goja builds.
|
||||||
|
const queryVar = "__sf_custom_search_query"
|
||||||
|
const optionsVar = "__sf_custom_search_options"
|
||||||
|
global := p.vm.GlobalObject()
|
||||||
|
_ = global.Set(queryVar, query)
|
||||||
|
_ = global.Set(optionsVar, options)
|
||||||
|
defer func() {
|
||||||
|
global.Delete(queryVar)
|
||||||
|
global.Delete(optionsVar)
|
||||||
|
}()
|
||||||
|
|
||||||
|
const script = `
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
|
||||||
return extension.customSearch(%q, %s);
|
return extension.customSearch(__sf_custom_search_query, __sf_custom_search_options);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})()
|
})()
|
||||||
`, query, string(optionsJSON))
|
`
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1358,12 +1376,12 @@ type PostProcessResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PostProcessInput struct {
|
type PostProcessInput struct {
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
URI string `json:"uri,omitempty"`
|
URI string `json:"uri,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
MimeType string `json:"mime_type,omitempty"`
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
Size int64 `json:"size,omitempty"`
|
Size int64 `json:"size,omitempty"`
|
||||||
IsSAF bool `json:"is_saf,omitempty"`
|
IsSAF bool `json:"is_saf,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostProcessTimeout = 2 * time.Minute
|
const PostProcessTimeout = 2 * time.Minute
|
||||||
|
|||||||
@@ -18,6 +18,43 @@ import (
|
|||||||
|
|
||||||
// ==================== Auth API (OAuth Support) ====================
|
// ==================== 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 {
|
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -32,6 +69,13 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
callbackURL = call.Arguments[1].String()
|
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()
|
pendingAuthRequestsMu.Lock()
|
||||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
ExtensionID: r.extensionID,
|
ExtensionID: r.extensionID,
|
||||||
@@ -50,7 +94,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
state.AuthCode = ""
|
state.AuthCode = ""
|
||||||
extensionAuthStateMu.Unlock()
|
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{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -273,6 +317,12 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
"error": "authUrl, clientId, and redirectUri are required",
|
"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)
|
scope, _ := config["scope"].(string)
|
||||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||||
@@ -331,7 +381,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
}
|
}
|
||||||
pendingAuthRequestsMu.Unlock()
|
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{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -441,13 +491,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
bodyPreview := sanitizeSensitiveLogText(string(body))
|
||||||
|
if len(bodyPreview) > 1000 {
|
||||||
|
bodyPreview = bodyPreview[:1000] + "...[truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
var tokenResp map[string]interface{}
|
var tokenResp map[string]interface{}
|
||||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
"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{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "no access_token in response",
|
"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" {
|
if parsed.Scheme != "https" {
|
||||||
return fmt.Errorf("network access denied: only https is allowed")
|
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()
|
domain := parsed.Hostname()
|
||||||
if domain == "" {
|
if domain == "" {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(storagePath, data, 0644)
|
return os.WriteFile(storagePath, data, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}}
|
}}
|
||||||
} else {
|
} else {
|
||||||
|
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,8 +31,22 @@ const (
|
|||||||
var (
|
var (
|
||||||
globalLogBuffer *LogBuffer
|
globalLogBuffer *LogBuffer
|
||||||
logBufferOnce sync.Once
|
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 {
|
func GetLogBuffer() *LogBuffer {
|
||||||
logBufferOnce.Do(func() {
|
logBufferOnce.Do(func() {
|
||||||
globalLogBuffer = &LogBuffer{
|
globalLogBuffer = &LogBuffer{
|
||||||
@@ -71,6 +86,7 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message = sanitizeSensitiveLogText(message)
|
||||||
message = truncateLogMessage(message)
|
message = truncateLogMessage(message)
|
||||||
|
|
||||||
entry := LogEntry{
|
entry := LogEntry{
|
||||||
|
|||||||
+160
-35
@@ -4,8 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
stdimage "image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -14,6 +19,82 @@ import (
|
|||||||
"github.com/go-flac/go-flac/v2"
|
"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 {
|
type Metadata struct {
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
@@ -127,19 +208,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picBlock, err := buildPictureBlock(coverPath, coverData)
|
||||||
flacpicture.PictureTypeFrontCover,
|
|
||||||
"Front Cover",
|
|
||||||
coverData,
|
|
||||||
"image/jpeg",
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
return fmt.Errorf("failed to create picture block: %w", err)
|
||||||
} else {
|
|
||||||
picBlock := picture.Marshal()
|
|
||||||
f.Meta = append(f.Meta, &picBlock)
|
|
||||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
||||||
}
|
}
|
||||||
|
f.Meta = append(f.Meta, &picBlock)
|
||||||
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
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(
|
picBlock, err := buildPictureBlock("", coverData)
|
||||||
flacpicture.PictureTypeFrontCover,
|
|
||||||
"Front Cover",
|
|
||||||
coverData,
|
|
||||||
"image/jpeg",
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
return fmt.Errorf("failed to create picture block: %w", err)
|
||||||
} else {
|
|
||||||
picBlock := picture.Marshal()
|
|
||||||
f.Meta = append(f.Meta, &picBlock)
|
|
||||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
||||||
}
|
}
|
||||||
|
f.Meta = append(f.Meta, &picBlock)
|
||||||
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
@@ -475,33 +542,91 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExtractLyrics(filePath string) (string, error) {
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
|
lower := strings.ToLower(filePath)
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".flac") {
|
||||||
|
return extractLyricsFromFlac(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".mp3") {
|
||||||
|
meta, err := ReadID3Tags(filePath)
|
||||||
|
if err != nil || meta == nil {
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
return meta.Lyrics, nil
|
||||||
|
}
|
||||||
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||||
|
return meta.Comment, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||||
|
meta, err := ReadOggVorbisComments(filePath)
|
||||||
|
if err != nil || meta == nil {
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
return meta.Lyrics, nil
|
||||||
|
}
|
||||||
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||||
|
return meta.Comment, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("unsupported file format for lyrics extraction")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLyricsFromFlac(filePath string) (string, error) {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, meta := range f.Meta {
|
for _, meta := range f.Meta {
|
||||||
if meta.Type == flac.VorbisComment {
|
if meta.Type != flac.VorbisComment {
|
||||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
continue
|
||||||
if err != nil {
|
}
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lyrics, err := cmt.Get("LYRICS")
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err != nil {
|
||||||
return lyrics[0], nil
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
|
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||||
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("no lyrics found in file")
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func looksLikeEmbeddedLyrics(value string) bool {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if strings.Contains(lower, "[ar:") || strings.Contains(lower, "[ti:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(trimmed, "\n") && strings.Contains(trimmed, "[") && strings.Contains(trimmed, "]") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type AudioQuality struct {
|
type AudioQuality struct {
|
||||||
BitDepth int `json:"bit_depth"`
|
BitDepth int `json:"bit_depth"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
|
|||||||
+3
-1
@@ -419,7 +419,7 @@ func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
|
|||||||
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
||||||
formatID := mapJumoQuality(quality)
|
formatID := mapJumoQuality(quality)
|
||||||
region := "US"
|
region := "US"
|
||||||
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
||||||
|
|
||||||
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
||||||
|
|
||||||
@@ -428,6 +428,8 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.6.0';
|
static const String version = '3.6.5';
|
||||||
static const String buildNumber = '77';
|
static const String buildNumber = '79';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +17,5 @@ class AppInfo {
|
|||||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
|
||||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
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/';
|
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -928,18 +928,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Support'**
|
/// **'Support'**
|
||||||
String get aboutSupport;
|
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
|
/// Section for app info
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3550,6 +3538,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'Artist folders use Track Artist only'**
|
/// **'Artist folders use Track Artist only'**
|
||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
|
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
|
/// Setting - output file format
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -5062,6 +5068,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Fetch and save lyrics as .lrc file'**
|
/// **'Fetch and save lyrics as .lrc file'**
|
||||||
String get trackSaveLyricsSubtitle;
|
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
|
/// Menu action - re-embed metadata into audio file
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -5133,6 +5145,70 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Failed: {error}'**
|
/// **'Failed: {error}'**
|
||||||
String trackSaveFailed(String error);
|
String trackSaveFailed(String error);
|
||||||
|
|
||||||
|
/// Menu item - convert audio format
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert Format'**
|
||||||
|
String get trackConvertFormat;
|
||||||
|
|
||||||
|
/// Subtitle for convert format menu item
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert to MP3 or Opus'**
|
||||||
|
String get trackConvertFormatSubtitle;
|
||||||
|
|
||||||
|
/// Title of convert bottom sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert Audio'**
|
||||||
|
String get trackConvertTitle;
|
||||||
|
|
||||||
|
/// Label for format selection
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Target Format'**
|
||||||
|
String get trackConvertTargetFormat;
|
||||||
|
|
||||||
|
/// Label for bitrate selection
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bitrate'**
|
||||||
|
String get trackConvertBitrate;
|
||||||
|
|
||||||
|
/// Confirmation dialog title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Confirm Conversion'**
|
||||||
|
String get trackConvertConfirmTitle;
|
||||||
|
|
||||||
|
/// Confirmation dialog message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.'**
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Snackbar while converting
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Converting audio...'**
|
||||||
|
String get trackConvertConverting;
|
||||||
|
|
||||||
|
/// Snackbar after successful conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Converted to {format} successfully'**
|
||||||
|
String trackConvertSuccess(String format);
|
||||||
|
|
||||||
|
/// Snackbar when conversion fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Conversion failed'**
|
||||||
|
String get trackConvertFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -469,13 +469,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Spendiere mir einen Kaffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle =>
|
|
||||||
'Unterstütze die Entwicklung auf Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1960,6 +1953,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2861,6 +2865,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2904,4 +2911,42 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,12 +457,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,4 +2897,42 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,12 +457,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,6 +2897,44 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||||
@@ -3327,12 +3373,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Soporte';
|
String get aboutSupport => 'Soporte';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Invítame a un café';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Apoyar el desarrollo en Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'Aplicación';
|
String get aboutApp => 'Aplicación';
|
||||||
|
|
||||||
|
|||||||
@@ -457,12 +457,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,4 +2897,42 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,12 +457,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,4 +2897,42 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -462,12 +462,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Dukungan';
|
String get aboutSupport => 'Dukungan';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Belikan saya kopi';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Dukung pengembangan di Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'Aplikasi';
|
String get aboutApp => 'Aplikasi';
|
||||||
|
|
||||||
@@ -1958,6 +1952,17 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Folder artis hanya memakai Track Artist';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => 'Simpan Format';
|
String get downloadSaveFormat => 'Simpan Format';
|
||||||
|
|
||||||
@@ -2864,6 +2869,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get trackSaveLyricsSubtitle =>
|
String get trackSaveLyricsSubtitle =>
|
||||||
'Ambil dan simpan lirik sebagai file .lrc';
|
'Ambil dan simpan lirik sebagai file .lrc';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Menyimpan lirik...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Perkaya Ulang Metadata';
|
String get trackReEnrich => 'Perkaya Ulang Metadata';
|
||||||
|
|
||||||
@@ -2908,4 +2916,42 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Gagal: $error';
|
return 'Gagal: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Konversi Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Konversi ke MP3 atau Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Konversi Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Format Tujuan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Konfirmasi Konversi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Konversi dari $sourceFormat ke $targetFormat pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Mengkonversi audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Berhasil dikonversi ke $format';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Konversi gagal';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -453,12 +453,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'サポート';
|
String get aboutSupport => 'サポート';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'コーヒーを買ってください';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'アプリ';
|
String get aboutApp => 'アプリ';
|
||||||
|
|
||||||
@@ -1933,6 +1927,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => '形式を保存';
|
String get downloadSaveFormat => '形式を保存';
|
||||||
|
|
||||||
@@ -2832,6 +2837,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2875,4 +2883,42 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,12 +457,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,4 +2897,42 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,12 +457,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,4 +2897,42 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,12 +457,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,6 +2897,44 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||||
@@ -3326,12 +3372,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Apoiar';
|
String get aboutSupport => 'Apoiar';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Compre-me um café';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Apoie o desenvolvimento na Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'Aplicativo';
|
String get aboutApp => 'Aplicativo';
|
||||||
|
|
||||||
|
|||||||
@@ -470,12 +470,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Поддержка';
|
String get aboutSupport => 'Поддержка';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Купить мне кофе';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Поддержать разработку на Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'Приложение';
|
String get aboutApp => 'Приложение';
|
||||||
|
|
||||||
@@ -1983,6 +1977,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => 'Формат сохранения';
|
String get downloadSaveFormat => 'Формат сохранения';
|
||||||
|
|
||||||
@@ -2892,6 +2897,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2935,4 +2943,42 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,12 +464,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Destek';
|
String get aboutSupport => 'Destek';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Bana bir kahve ısmarla';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi üzerinden uygulamayı destekle';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'Uygulama';
|
String get aboutApp => 'Uygulama';
|
||||||
|
|
||||||
@@ -1960,6 +1954,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2861,6 +2866,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2904,4 +2912,42 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,12 +457,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1945,6 +1939,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2851,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,6 +2897,44 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||||
@@ -3337,12 +3383,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -5483,12 +5523,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
|
|||||||
@@ -576,14 +576,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
+42
-5
@@ -326,10 +326,6 @@
|
|||||||
"@aboutSocial": {"description": "Section for social links"},
|
"@aboutSocial": {"description": "Section for social links"},
|
||||||
"aboutSupport": "Support",
|
"aboutSupport": "Support",
|
||||||
"@aboutSupport": {"description": "Section for support/donation links"},
|
"@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": "App",
|
||||||
"@aboutApp": {"description": "Section for app info"},
|
"@aboutApp": {"description": "Section for app info"},
|
||||||
"aboutVersion": "Version",
|
"aboutVersion": "Version",
|
||||||
@@ -1431,6 +1427,12 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
|
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
|
||||||
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
|
"@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": "Save Format",
|
||||||
"@downloadSaveFormat": {"description": "Setting - output file format"},
|
"@downloadSaveFormat": {"description": "Setting - output file format"},
|
||||||
"downloadSelectService": "Select Service",
|
"downloadSelectService": "Select Service",
|
||||||
@@ -2144,6 +2146,8 @@
|
|||||||
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
||||||
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
|
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
|
||||||
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
||||||
|
"trackSaveLyricsProgress": "Saving lyrics...",
|
||||||
|
"@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"},
|
||||||
"trackReEnrich": "Re-enrich Metadata",
|
"trackReEnrich": "Re-enrich Metadata",
|
||||||
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
|
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
|
||||||
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
|
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
|
||||||
@@ -2182,5 +2186,38 @@
|
|||||||
"placeholders": {
|
"placeholders": {
|
||||||
"error": {"type": "String"}
|
"error": {"type": "String"}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
"trackConvertFormat": "Convert Format",
|
||||||
|
"@trackConvertFormat": {"description": "Menu item - convert audio format"},
|
||||||
|
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||||
|
"@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"},
|
||||||
|
"trackConvertTitle": "Convert Audio",
|
||||||
|
"@trackConvertTitle": {"description": "Title of convert bottom sheet"},
|
||||||
|
"trackConvertTargetFormat": "Target Format",
|
||||||
|
"@trackConvertTargetFormat": {"description": "Label for format selection"},
|
||||||
|
"trackConvertBitrate": "Bitrate",
|
||||||
|
"@trackConvertBitrate": {"description": "Label for bitrate selection"},
|
||||||
|
"trackConvertConfirmTitle": "Confirm Conversion",
|
||||||
|
"@trackConvertConfirmTitle": {"description": "Confirmation dialog title"},
|
||||||
|
"trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.",
|
||||||
|
"@trackConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {"type": "String"},
|
||||||
|
"targetFormat": {"type": "String"},
|
||||||
|
"bitrate": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertConverting": "Converting audio...",
|
||||||
|
"@trackConvertConverting": {"description": "Snackbar while converting"},
|
||||||
|
"trackConvertSuccess": "Converted to {format} successfully",
|
||||||
|
"@trackConvertSuccess": {
|
||||||
|
"description": "Snackbar after successful conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"format": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertFailed": "Conversion failed",
|
||||||
|
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "Aplicación",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -576,14 +576,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -576,14 +576,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
+53
-14
@@ -588,14 +588,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "Aplikasi",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
@@ -2489,6 +2481,18 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"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": "Simpan Format",
|
||||||
"@downloadSaveFormat": {
|
"@downloadSaveFormat": {
|
||||||
"description": "Setting - output file format"
|
"description": "Setting - output file format"
|
||||||
@@ -3160,11 +3164,13 @@
|
|||||||
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
|
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
|
||||||
"trackSaveCoverArtSubtitle": "Simpan cover album sebagai file .jpg",
|
"trackSaveCoverArtSubtitle": "Simpan cover album sebagai file .jpg",
|
||||||
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
|
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
|
||||||
"trackSaveLyrics": "Simpan Lirik (.lrc)",
|
"trackSaveLyrics": "Simpan Lirik (.lrc)",
|
||||||
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
||||||
"trackSaveLyricsSubtitle": "Ambil dan simpan lirik sebagai file .lrc",
|
"trackSaveLyricsSubtitle": "Ambil dan simpan lirik sebagai file .lrc",
|
||||||
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
||||||
"trackReEnrich": "Perkaya Ulang Metadata",
|
"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"},
|
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
|
||||||
"trackReEnrichSubtitle": "Tanamkan ulang metadata tanpa mengunduh ulang",
|
"trackReEnrichSubtitle": "Tanamkan ulang metadata tanpa mengunduh ulang",
|
||||||
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
|
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
|
||||||
@@ -3202,5 +3208,38 @@
|
|||||||
"placeholders": {
|
"placeholders": {
|
||||||
"error": {"type": "String"}
|
"error": {"type": "String"}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
"trackConvertFormat": "Konversi Format",
|
||||||
|
"@trackConvertFormat": {"description": "Menu item - convert audio format"},
|
||||||
|
"trackConvertFormatSubtitle": "Konversi ke MP3 atau Opus",
|
||||||
|
"@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"},
|
||||||
|
"trackConvertTitle": "Konversi Audio",
|
||||||
|
"@trackConvertTitle": {"description": "Title of convert bottom sheet"},
|
||||||
|
"trackConvertTargetFormat": "Format Tujuan",
|
||||||
|
"@trackConvertTargetFormat": {"description": "Label for format selection"},
|
||||||
|
"trackConvertBitrate": "Bitrate",
|
||||||
|
"@trackConvertBitrate": {"description": "Label for bitrate selection"},
|
||||||
|
"trackConvertConfirmTitle": "Konfirmasi Konversi",
|
||||||
|
"@trackConvertConfirmTitle": {"description": "Confirmation dialog title"},
|
||||||
|
"trackConvertConfirmMessage": "Konversi dari {sourceFormat} ke {targetFormat} pada {bitrate}?\n\nFile asli akan dihapus setelah konversi.",
|
||||||
|
"@trackConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {"type": "String"},
|
||||||
|
"targetFormat": {"type": "String"},
|
||||||
|
"bitrate": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertConverting": "Mengkonversi audio...",
|
||||||
|
"@trackConvertConverting": {"description": "Snackbar while converting"},
|
||||||
|
"trackConvertSuccess": "Berhasil dikonversi ke {format}",
|
||||||
|
"@trackConvertSuccess": {
|
||||||
|
"description": "Snackbar after successful conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"format": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertFailed": "Konversi gagal",
|
||||||
|
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -576,14 +576,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "コーヒーを買ってください",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Ko-fi で開発をサポートします",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "アプリ",
|
"aboutApp": "アプリ",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -576,14 +576,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -576,14 +576,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "Aplicativo",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -576,14 +576,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Купить мне кофе",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Поддержать разработку на Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "Приложение",
|
"aboutApp": "Приложение",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -576,14 +576,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "Uygulama",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -576,14 +576,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -576,14 +576,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"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": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -11,12 +11,21 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
|||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
_configureImageCache();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
|
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
|
/// Widget to eagerly initialize providers that need to load data on startup
|
||||||
class _EagerInitialization extends ConsumerStatefulWidget {
|
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||||
const _EagerInitialization({required this.child});
|
const _EagerInitialization({required this.child});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class AppSettings {
|
|||||||
final bool hasSearchedBefore;
|
final bool hasSearchedBefore;
|
||||||
final String folderOrganization;
|
final String folderOrganization;
|
||||||
final bool useAlbumArtistForFolders;
|
final bool useAlbumArtistForFolders;
|
||||||
|
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
|
||||||
final String historyViewMode;
|
final String historyViewMode;
|
||||||
final String historyFilterMode;
|
final String historyFilterMode;
|
||||||
final bool askQualityBeforeDownload;
|
final bool askQualityBeforeDownload;
|
||||||
@@ -65,6 +66,7 @@ class AppSettings {
|
|||||||
this.hasSearchedBefore = false,
|
this.hasSearchedBefore = false,
|
||||||
this.folderOrganization = 'none',
|
this.folderOrganization = 'none',
|
||||||
this.useAlbumArtistForFolders = true,
|
this.useAlbumArtistForFolders = true,
|
||||||
|
this.usePrimaryArtistOnly = false,
|
||||||
this.historyViewMode = 'grid',
|
this.historyViewMode = 'grid',
|
||||||
this.historyFilterMode = 'all',
|
this.historyFilterMode = 'all',
|
||||||
this.askQualityBeforeDownload = true,
|
this.askQualityBeforeDownload = true,
|
||||||
@@ -109,6 +111,7 @@ class AppSettings {
|
|||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
bool? useAlbumArtistForFolders,
|
bool? useAlbumArtistForFolders,
|
||||||
|
bool? usePrimaryArtistOnly,
|
||||||
String? historyViewMode,
|
String? historyViewMode,
|
||||||
String? historyFilterMode,
|
String? historyFilterMode,
|
||||||
bool? askQualityBeforeDownload,
|
bool? askQualityBeforeDownload,
|
||||||
@@ -154,6 +157,8 @@ class AppSettings {
|
|||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
useAlbumArtistForFolders:
|
useAlbumArtistForFolders:
|
||||||
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
||||||
|
usePrimaryArtistOnly:
|
||||||
|
usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
|
||||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
||||||
|
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
|
||||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
@@ -70,6 +71,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||||
|
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||||
'historyViewMode': instance.historyViewMode,
|
'historyViewMode': instance.historyViewMode,
|
||||||
'historyFilterMode': instance.historyFilterMode,
|
'historyFilterMode': instance.historyFilterMode,
|
||||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
'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/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.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/ffmpeg_service.dart';
|
||||||
import 'package:spotiflac_android/services/notification_service.dart';
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/history_database.dart';
|
import 'package:spotiflac_android/services/history_database.dart';
|
||||||
@@ -150,20 +151,37 @@ class DownloadHistoryItem {
|
|||||||
);
|
);
|
||||||
|
|
||||||
DownloadHistoryItem copyWith({
|
DownloadHistoryItem copyWith({
|
||||||
|
String? trackName,
|
||||||
|
String? artistName,
|
||||||
|
String? albumName,
|
||||||
|
String? albumArtist,
|
||||||
|
String? coverUrl,
|
||||||
String? filePath,
|
String? filePath,
|
||||||
String? storageMode,
|
String? storageMode,
|
||||||
String? downloadTreeUri,
|
String? downloadTreeUri,
|
||||||
String? safRelativeDir,
|
String? safRelativeDir,
|
||||||
String? safFileName,
|
String? safFileName,
|
||||||
bool? safRepaired,
|
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(
|
return DownloadHistoryItem(
|
||||||
id: id,
|
id: id,
|
||||||
trackName: trackName,
|
trackName: trackName ?? this.trackName,
|
||||||
artistName: artistName,
|
artistName: artistName ?? this.artistName,
|
||||||
albumName: albumName,
|
albumName: albumName ?? this.albumName,
|
||||||
albumArtist: albumArtist,
|
albumArtist: albumArtist ?? this.albumArtist,
|
||||||
coverUrl: coverUrl,
|
coverUrl: coverUrl ?? this.coverUrl,
|
||||||
filePath: filePath ?? this.filePath,
|
filePath: filePath ?? this.filePath,
|
||||||
storageMode: storageMode ?? this.storageMode,
|
storageMode: storageMode ?? this.storageMode,
|
||||||
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
||||||
@@ -172,34 +190,29 @@ class DownloadHistoryItem {
|
|||||||
safRepaired: safRepaired ?? this.safRepaired,
|
safRepaired: safRepaired ?? this.safRepaired,
|
||||||
service: service,
|
service: service,
|
||||||
downloadedAt: downloadedAt,
|
downloadedAt: downloadedAt,
|
||||||
isrc: isrc,
|
isrc: isrc ?? this.isrc,
|
||||||
spotifyId: spotifyId,
|
spotifyId: spotifyId ?? this.spotifyId,
|
||||||
trackNumber: trackNumber,
|
trackNumber: trackNumber ?? this.trackNumber,
|
||||||
discNumber: discNumber,
|
discNumber: discNumber ?? this.discNumber,
|
||||||
duration: duration,
|
duration: duration ?? this.duration,
|
||||||
releaseDate: releaseDate,
|
releaseDate: releaseDate ?? this.releaseDate,
|
||||||
quality: quality,
|
quality: quality ?? this.quality,
|
||||||
bitDepth: bitDepth,
|
bitDepth: bitDepth ?? this.bitDepth,
|
||||||
sampleRate: sampleRate,
|
sampleRate: sampleRate ?? this.sampleRate,
|
||||||
genre: genre,
|
genre: genre ?? this.genre,
|
||||||
label: label,
|
label: label ?? this.label,
|
||||||
copyright: copyright,
|
copyright: copyright ?? this.copyright,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DownloadHistoryState {
|
class DownloadHistoryState {
|
||||||
final List<DownloadHistoryItem> items;
|
final List<DownloadHistoryItem> items;
|
||||||
final Set<String> _downloadedSpotifyIds;
|
|
||||||
final Map<String, DownloadHistoryItem> _bySpotifyId;
|
final Map<String, DownloadHistoryItem> _bySpotifyId;
|
||||||
final Map<String, DownloadHistoryItem> _byIsrc;
|
final Map<String, DownloadHistoryItem> _byIsrc;
|
||||||
|
|
||||||
DownloadHistoryState({this.items = const []})
|
DownloadHistoryState({this.items = const []})
|
||||||
: _downloadedSpotifyIds = items
|
: _bySpotifyId = Map.fromEntries(
|
||||||
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
|
||||||
.map((item) => item.spotifyId!)
|
|
||||||
.toSet(),
|
|
||||||
_bySpotifyId = Map.fromEntries(
|
|
||||||
items
|
items
|
||||||
.where(
|
.where(
|
||||||
(item) => item.spotifyId != null && item.spotifyId!.isNotEmpty,
|
(item) => item.spotifyId != null && item.spotifyId!.isNotEmpty,
|
||||||
@@ -212,8 +225,7 @@ class DownloadHistoryState {
|
|||||||
.map((item) => MapEntry(item.isrc!, item)),
|
.map((item) => MapEntry(item.isrc!, item)),
|
||||||
);
|
);
|
||||||
|
|
||||||
bool isDownloaded(String spotifyId) =>
|
bool isDownloaded(String spotifyId) => _bySpotifyId.containsKey(spotifyId);
|
||||||
_downloadedSpotifyIds.contains(spotifyId);
|
|
||||||
|
|
||||||
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
|
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
|
||||||
_bySpotifyId[spotifyId];
|
_bySpotifyId[spotifyId];
|
||||||
@@ -462,6 +474,44 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
return DownloadHistoryItem.fromJson(json);
|
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
|
/// Remove history entries where the file no longer exists on disk
|
||||||
/// Returns the number of orphaned entries removed
|
/// Returns the number of orphaned entries removed
|
||||||
Future<int> cleanupOrphanedDownloads() async {
|
Future<int> cleanupOrphanedDownloads() async {
|
||||||
@@ -469,31 +519,36 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
|
|
||||||
final entries = await _db.getAllEntriesWithPaths();
|
final entries = await _db.getAllEntriesWithPaths();
|
||||||
final orphanedIds = <String>[];
|
final orphanedIds = <String>[];
|
||||||
|
final pathById = <String, String>{};
|
||||||
|
const checkChunkSize = 16;
|
||||||
|
|
||||||
for (final entry in entries) {
|
for (var i = 0; i < entries.length; i += checkChunkSize) {
|
||||||
final id = entry['id'] as String;
|
final end = (i + checkChunkSize < entries.length)
|
||||||
final filePath = entry['file_path'] as String?;
|
? 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;
|
for (final check in checks) {
|
||||||
|
if (check == null || check.value) continue;
|
||||||
if (filePath.startsWith('content://')) {
|
orphanedIds.add(check.key);
|
||||||
// SAF path - check via platform bridge
|
_historyLog.d(
|
||||||
try {
|
'Found orphaned entry: ${check.key} (${pathById[check.key] ?? ''})',
|
||||||
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)');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,6 +672,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
static const _queueStorageKey = 'download_queue';
|
static const _queueStorageKey = 'download_queue';
|
||||||
static const _progressPollingInterval = Duration(milliseconds: 800);
|
static const _progressPollingInterval = Duration(milliseconds: 800);
|
||||||
static const _queueSchedulingInterval = Duration(milliseconds: 250);
|
static const _queueSchedulingInterval = Duration(milliseconds: 250);
|
||||||
|
static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI.
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
int _totalQueuedAtStart = 0;
|
int _totalQueuedAtStart = 0;
|
||||||
@@ -625,11 +681,61 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
final Set<String> _ensuredDirs = {};
|
final Set<String> _ensuredDirs = {};
|
||||||
int _progressPollingErrorCount = 0;
|
int _progressPollingErrorCount = 0;
|
||||||
|
bool _isProgressPollingInFlight = false;
|
||||||
String? _lastServiceTrackName;
|
String? _lastServiceTrackName;
|
||||||
String? _lastServiceArtistName;
|
String? _lastServiceArtistName;
|
||||||
int _lastServicePercent = -1;
|
int _lastServicePercent = -1;
|
||||||
int _lastServiceQueueCount = -1;
|
int _lastServiceQueueCount = -1;
|
||||||
DateTime _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
|
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
|
@override
|
||||||
DownloadQueueState build() {
|
DownloadQueueState build() {
|
||||||
@@ -726,6 +832,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
void _startMultiProgressPolling() {
|
void _startMultiProgressPolling() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = Timer.periodic(_progressPollingInterval, (timer) async {
|
_progressTimer = Timer.periodic(_progressPollingInterval, (timer) async {
|
||||||
|
if (_isProgressPollingInFlight) return;
|
||||||
|
_isProgressPollingInFlight = true;
|
||||||
try {
|
try {
|
||||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||||
@@ -798,24 +906,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
} else {
|
} else {
|
||||||
percentage = progressFromBackend;
|
percentage = progressFromBackend;
|
||||||
}
|
}
|
||||||
|
final normalizedProgress = _normalizeProgressForUi(percentage);
|
||||||
|
final normalizedSpeed = _normalizeSpeedForUi(speedMBps);
|
||||||
|
final normalizedBytes = _normalizeBytesForUi(bytesReceived);
|
||||||
|
|
||||||
progressUpdates[itemId] = _ProgressUpdate(
|
progressUpdates[itemId] = _ProgressUpdate(
|
||||||
status: DownloadStatus.downloading,
|
status: DownloadStatus.downloading,
|
||||||
progress: percentage,
|
progress: normalizedProgress,
|
||||||
speedMBps: speedMBps,
|
speedMBps: normalizedSpeed,
|
||||||
bytesReceived: bytesReceived,
|
bytesReceived: normalizedBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
final mbReceived = bytesReceived / (1024 * 1024);
|
if (LogBuffer.loggingEnabled) {
|
||||||
final mbTotal = bytesTotal / (1024 * 1024);
|
final mbReceived = bytesReceived / (1024 * 1024);
|
||||||
if (bytesTotal > 0) {
|
final mbTotal = bytesTotal / (1024 * 1024);
|
||||||
_log.d(
|
if (bytesTotal > 0) {
|
||||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
_log.d(
|
||||||
);
|
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||||
} else {
|
);
|
||||||
_log.d(
|
} else {
|
||||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
_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) {
|
if (hasFinalizingItem && finalizingTrackName != null) {
|
||||||
_notificationService.showDownloadFinalizing(
|
final safeArtistName = finalizingArtistName ?? '';
|
||||||
trackName: finalizingTrackName,
|
if (finalizingTrackName != _lastFinalizingTrackName ||
|
||||||
artistName: finalizingArtistName ?? '',
|
safeArtistName != _lastFinalizingArtistName) {
|
||||||
);
|
_notificationService.showDownloadFinalizing(
|
||||||
|
trackName: finalizingTrackName,
|
||||||
|
artistName: safeArtistName,
|
||||||
|
);
|
||||||
|
_lastFinalizingTrackName = finalizingTrackName;
|
||||||
|
_lastFinalizingArtistName = safeArtistName;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_lastFinalizingTrackName = null;
|
||||||
|
_lastFinalizingArtistName = null;
|
||||||
|
|
||||||
if (items.isNotEmpty) {
|
if (items.isNotEmpty) {
|
||||||
final firstEntry = items.entries.first;
|
final firstEntry = items.entries.first;
|
||||||
@@ -889,19 +1010,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
notifTotal = 100;
|
notifTotal = 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
_notificationService.showDownloadProgress(
|
final safeNotifTotal = notifTotal > 0 ? notifTotal : 1;
|
||||||
|
if (_shouldUpdateProgressNotification(
|
||||||
trackName: trackName,
|
trackName: trackName,
|
||||||
artistName: artistName,
|
artistName: artistName,
|
||||||
progress: notifProgress,
|
progress: notifProgress,
|
||||||
total: notifTotal > 0 ? notifTotal : 1,
|
total: safeNotifTotal,
|
||||||
);
|
queueCount: queuedCount,
|
||||||
|
)) {
|
||||||
|
_notificationService.showDownloadProgress(
|
||||||
|
trackName: trackName,
|
||||||
|
artistName: artistName,
|
||||||
|
progress: notifProgress,
|
||||||
|
total: safeNotifTotal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
_maybeUpdateAndroidDownloadService(
|
_maybeUpdateAndroidDownloadService(
|
||||||
trackName: firstDownloading.track.name,
|
trackName: firstDownloading.track.name,
|
||||||
artistName: firstDownloading.track.artistName,
|
artistName: firstDownloading.track.artistName,
|
||||||
progress: notifProgress,
|
progress: notifProgress,
|
||||||
total: notifTotal > 0 ? notifTotal : 1,
|
total: safeNotifTotal,
|
||||||
queueCount: queuedCount,
|
queueCount: queuedCount,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -913,6 +1043,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (_progressPollingErrorCount <= 3) {
|
if (_progressPollingErrorCount <= 3) {
|
||||||
_log.w('Progress polling failed: $e');
|
_log.w('Progress polling failed: $e');
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
_isProgressPollingInFlight = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -962,11 +1094,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = null;
|
_progressTimer = null;
|
||||||
_progressPollingErrorCount = 0;
|
_progressPollingErrorCount = 0;
|
||||||
|
_isProgressPollingInFlight = false;
|
||||||
_lastServiceTrackName = null;
|
_lastServiceTrackName = null;
|
||||||
_lastServiceArtistName = null;
|
_lastServiceArtistName = null;
|
||||||
_lastServicePercent = -1;
|
_lastServicePercent = -1;
|
||||||
_lastServiceQueueCount = -1;
|
_lastServiceQueueCount = -1;
|
||||||
_lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
|
_lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
_lastFinalizingTrackName = null;
|
||||||
|
_lastFinalizingArtistName = null;
|
||||||
|
_lastNotifTrackName = null;
|
||||||
|
_lastNotifArtistName = null;
|
||||||
|
_lastNotifPercent = -1;
|
||||||
|
_lastNotifQueueCount = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initOutputDir() async {
|
Future<void> _initOutputDir() async {
|
||||||
@@ -1033,11 +1172,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
bool separateSingles = false,
|
bool separateSingles = false,
|
||||||
String albumFolderStructure = 'artist_album',
|
String albumFolderStructure = 'artist_album',
|
||||||
bool useAlbumArtistForFolders = true,
|
bool useAlbumArtistForFolders = true,
|
||||||
|
bool usePrimaryArtistOnly = false,
|
||||||
}) async {
|
}) async {
|
||||||
String baseDir = state.outputDir;
|
String baseDir = state.outputDir;
|
||||||
final folderArtist = useAlbumArtistForFolders
|
var folderArtist = useAlbumArtistForFolders
|
||||||
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
|
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
|
||||||
: track.artistName;
|
: track.artistName;
|
||||||
|
if (usePrimaryArtistOnly) {
|
||||||
|
folderArtist = _extractPrimaryArtist(folderArtist);
|
||||||
|
}
|
||||||
|
|
||||||
if (separateSingles) {
|
if (separateSingles) {
|
||||||
final isSingle = track.isSingle;
|
final isSingle = track.isSingle;
|
||||||
@@ -1129,6 +1272,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
.trim();
|
.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) {
|
bool _isSafMode(AppSettings settings) {
|
||||||
return Platform.isAndroid &&
|
return Platform.isAndroid &&
|
||||||
settings.storageMode == 'saf' &&
|
settings.storageMode == 'saf' &&
|
||||||
@@ -1152,10 +1308,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
bool separateSingles = false,
|
bool separateSingles = false,
|
||||||
String albumFolderStructure = 'artist_album',
|
String albumFolderStructure = 'artist_album',
|
||||||
bool useAlbumArtistForFolders = true,
|
bool useAlbumArtistForFolders = true,
|
||||||
|
bool usePrimaryArtistOnly = false,
|
||||||
}) async {
|
}) async {
|
||||||
final folderArtist = useAlbumArtistForFolders
|
var folderArtist = useAlbumArtistForFolders
|
||||||
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
|
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
|
||||||
: track.artistName;
|
: track.artistName;
|
||||||
|
if (usePrimaryArtistOnly) {
|
||||||
|
folderArtist = _extractPrimaryArtist(folderArtist);
|
||||||
|
}
|
||||||
|
|
||||||
if (separateSingles) {
|
if (separateSingles) {
|
||||||
final isSingle = track.isSingle;
|
final isSingle = track.isSingle;
|
||||||
@@ -1215,6 +1375,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
return '.opus';
|
return '.opus';
|
||||||
}
|
}
|
||||||
|
// Amazon stream is delivered as MP4/M4A container (may contain FLAC audio),
|
||||||
|
// so SAF should keep .m4a before decrypt/convert pipeline.
|
||||||
|
if (service.toLowerCase() == 'amazon') {
|
||||||
|
return '.m4a';
|
||||||
|
}
|
||||||
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
|
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
|
||||||
return '.m4a';
|
return '.m4a';
|
||||||
}
|
}
|
||||||
@@ -2565,6 +2730,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
separateSingles: settings.separateSingles,
|
separateSingles: settings.separateSingles,
|
||||||
albumFolderStructure: settings.albumFolderStructure,
|
albumFolderStructure: settings.albumFolderStructure,
|
||||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||||
|
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||||
)
|
)
|
||||||
: '';
|
: '';
|
||||||
String? appOutputDir;
|
String? appOutputDir;
|
||||||
@@ -2576,6 +2742,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
separateSingles: settings.separateSingles,
|
separateSingles: settings.separateSingles,
|
||||||
albumFolderStructure: settings.albumFolderStructure,
|
albumFolderStructure: settings.albumFolderStructure,
|
||||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||||
|
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||||
);
|
);
|
||||||
var effectiveOutputDir = initialOutputDir;
|
var effectiveOutputDir = initialOutputDir;
|
||||||
var effectiveSafMode = isSafMode;
|
var effectiveSafMode = isSafMode;
|
||||||
@@ -2641,8 +2808,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (spotifyId.startsWith('spotify:track:')) {
|
if (spotifyId.startsWith('spotify:track:')) {
|
||||||
spotifyId = spotifyId.split(':').last;
|
spotifyId = spotifyId.split(':').last;
|
||||||
}
|
}
|
||||||
_log.d('No Deezer ID, converting from Spotify via SongLink: $spotifyId');
|
_log.d(
|
||||||
final deezerData = await PlatformBridge.convertSpotifyToDeezer('track', spotifyId);
|
'No Deezer ID, converting from Spotify via SongLink: $spotifyId',
|
||||||
|
);
|
||||||
|
final deezerData = await PlatformBridge.convertSpotifyToDeezer(
|
||||||
|
'track',
|
||||||
|
spotifyId,
|
||||||
|
);
|
||||||
// Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}}
|
// Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}}
|
||||||
final trackData = deezerData['track'];
|
final trackData = deezerData['track'];
|
||||||
if (trackData is Map<String, dynamic>) {
|
if (trackData is Map<String, dynamic>) {
|
||||||
@@ -2652,20 +2824,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.d('Found Deezer track ID via SongLink: $deezerTrackId');
|
_log.d('Found Deezer track ID via SongLink: $deezerTrackId');
|
||||||
} else if (deezerData['id'] != null) {
|
} else if (deezerData['id'] != null) {
|
||||||
deezerTrackId = deezerData['id'].toString();
|
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.)
|
// Enrich track metadata from Deezer response (release_date, isrc, etc.)
|
||||||
final deezerReleaseDate = _normalizeOptionalString(trackData['release_date'] as String?);
|
final deezerReleaseDate = _normalizeOptionalString(
|
||||||
final deezerIsrc = _normalizeOptionalString(trackData['isrc'] as String?);
|
trackData['release_date'] as String?,
|
||||||
|
);
|
||||||
|
final deezerIsrc = _normalizeOptionalString(
|
||||||
|
trackData['isrc'] as String?,
|
||||||
|
);
|
||||||
final deezerTrackNum = trackData['track_number'] as int?;
|
final deezerTrackNum = trackData['track_number'] as int?;
|
||||||
final deezerDiscNum = trackData['disc_number'] as int?;
|
final deezerDiscNum = trackData['disc_number'] as int?;
|
||||||
|
|
||||||
final needsEnrich =
|
final needsEnrich =
|
||||||
(trackToDownload.releaseDate == null && deezerReleaseDate != null) ||
|
(trackToDownload.releaseDate == null &&
|
||||||
|
deezerReleaseDate != null) ||
|
||||||
(trackToDownload.isrc == null && deezerIsrc != null) ||
|
(trackToDownload.isrc == null && deezerIsrc != null) ||
|
||||||
(!_isValidISRC(trackToDownload.isrc ?? '') && deezerIsrc != null) ||
|
(!_isValidISRC(trackToDownload.isrc ?? '') &&
|
||||||
(trackToDownload.trackNumber == null && deezerTrackNum != null) ||
|
deezerIsrc != null) ||
|
||||||
|
(trackToDownload.trackNumber == null &&
|
||||||
|
deezerTrackNum != null) ||
|
||||||
(trackToDownload.discNumber == null && deezerDiscNum != null);
|
(trackToDownload.discNumber == null && deezerDiscNum != null);
|
||||||
|
|
||||||
if (needsEnrich) {
|
if (needsEnrich) {
|
||||||
@@ -2688,7 +2869,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
albumType: trackToDownload.albumType,
|
albumType: trackToDownload.albumType,
|
||||||
source: trackToDownload.source,
|
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) {
|
} else if (deezerData['id'] != null) {
|
||||||
deezerTrackId = deezerData['id'].toString();
|
deezerTrackId = deezerData['id'].toString();
|
||||||
@@ -2733,129 +2916,64 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final relativeDir = useSaf ? outputDir : '';
|
final relativeDir = useSaf ? outputDir : '';
|
||||||
final fileName = useSaf ? (safFileName ?? '') : '';
|
final fileName = useSaf ? (safFileName ?? '') : '';
|
||||||
final outputExt = useSaf ? safOutputExt : '';
|
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 (isYouTube) {
|
||||||
if (item.service == 'youtube') {
|
|
||||||
_log.d('Using YouTube/Cobalt provider for download');
|
_log.d('Using YouTube/Cobalt provider for download');
|
||||||
_log.d('Quality: $quality (lossy only)');
|
_log.d('Quality: $quality (lossy only)');
|
||||||
_log.d('Output dir: $outputDir');
|
} else if (shouldUseExtensions) {
|
||||||
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) {
|
|
||||||
_log.d('Using extension providers for download');
|
_log.d('Using extension providers for download');
|
||||||
_log.d(
|
_log.d(
|
||||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||||
);
|
);
|
||||||
_log.d('Output dir: $outputDir');
|
} else if (shouldUseFallback) {
|
||||||
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) {
|
|
||||||
_log.d('Using auto-fallback mode');
|
_log.d('Using auto-fallback mode');
|
||||||
_log.d(
|
_log.d(
|
||||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
'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 ?? '',
|
isrc: trackToDownload.isrc ?? '',
|
||||||
service: item.service,
|
service: item.service,
|
||||||
spotifyId: trackToDownload.id,
|
spotifyId: trackToDownload.id,
|
||||||
trackName: trackToDownload.name,
|
trackName: trackToDownload.name,
|
||||||
artistName: trackToDownload.artistName,
|
artistName: trackToDownload.artistName,
|
||||||
albumName: trackToDownload.albumName,
|
albumName: trackToDownload.albumName,
|
||||||
albumArtist: normalizedAlbumArtist,
|
albumArtist: normalizedAlbumArtist ?? trackToDownload.artistName,
|
||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl ?? '',
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
|
// Keep prior behavior: non-YouTube paths were implicitly true.
|
||||||
|
embedLyrics: isYouTube ? settings.embedLyrics : true,
|
||||||
|
embedMaxQualityCover: settings.maxQualityCover,
|
||||||
trackNumber: trackToDownload.trackNumber ?? 1,
|
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||||
discNumber: trackToDownload.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: trackToDownload.releaseDate,
|
releaseDate: trackToDownload.releaseDate ?? '',
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
durationMs: trackToDownload.duration,
|
durationMs: trackToDownload.duration,
|
||||||
|
source: trackToDownload.source ?? '',
|
||||||
|
genre: genre ?? '',
|
||||||
|
label: label ?? '',
|
||||||
|
deezerId: deezerTrackId ?? '',
|
||||||
|
lyricsMode: settings.lyricsMode,
|
||||||
storageMode: storageMode,
|
storageMode: storageMode,
|
||||||
safTreeUri: treeUri,
|
safTreeUri: treeUri,
|
||||||
safRelativeDir: relativeDir,
|
safRelativeDir: relativeDir,
|
||||||
safFileName: fileName,
|
safFileName: fileName,
|
||||||
safOutputExt: outputExt,
|
safOutputExt: outputExt,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return PlatformBridge.downloadByStrategy(
|
||||||
|
payload: payload,
|
||||||
|
useExtensions: shouldUseExtensions,
|
||||||
|
useFallback: shouldUseFallback,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await runDownload(
|
result = await runDownload(
|
||||||
@@ -2873,6 +2991,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
separateSingles: settings.separateSingles,
|
separateSingles: settings.separateSingles,
|
||||||
albumFolderStructure: settings.albumFolderStructure,
|
albumFolderStructure: settings.albumFolderStructure,
|
||||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||||
|
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||||
);
|
);
|
||||||
final fallbackResult = await runDownload(
|
final fallbackResult = await runDownload(
|
||||||
useSaf: false,
|
useSaf: false,
|
||||||
@@ -2937,6 +3056,117 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final actualService =
|
final actualService =
|
||||||
((result['service'] as String?)?.toLowerCase()) ??
|
((result['service'] as String?)?.toLowerCase()) ??
|
||||||
item.service.toLowerCase();
|
item.service.toLowerCase();
|
||||||
|
final decryptionKey =
|
||||||
|
(result['decryption_key'] as String?)?.trim() ?? '';
|
||||||
|
|
||||||
|
if (!wasExisting &&
|
||||||
|
decryptionKey.isNotEmpty &&
|
||||||
|
filePath != null &&
|
||||||
|
actualService == 'amazon') {
|
||||||
|
_log.i('Amazon encrypted stream detected, decrypting via FFmpeg...');
|
||||||
|
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
|
||||||
|
|
||||||
|
if (effectiveSafMode && isContentUri(filePath)) {
|
||||||
|
final currentFilePath = filePath;
|
||||||
|
final tempPath = await _copySafToTemp(currentFilePath);
|
||||||
|
if (tempPath == null) {
|
||||||
|
_log.e('Failed to copy encrypted SAF file to temp for decrypt');
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.failed,
|
||||||
|
error: 'Failed to access encrypted SAF file',
|
||||||
|
errorType: DownloadErrorType.unknown,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? decryptedTempPath;
|
||||||
|
try {
|
||||||
|
decryptedTempPath = await FFmpegService.decryptAudioFile(
|
||||||
|
inputPath: tempPath,
|
||||||
|
decryptionKey: decryptionKey,
|
||||||
|
deleteOriginal: false,
|
||||||
|
);
|
||||||
|
if (decryptedTempPath == null) {
|
||||||
|
_log.e('FFmpeg decrypt failed for SAF file');
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.failed,
|
||||||
|
error: 'Failed to decrypt Amazon stream',
|
||||||
|
errorType: DownloadErrorType.unknown,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dotIndex = decryptedTempPath.lastIndexOf('.');
|
||||||
|
final decryptedExt = dotIndex >= 0
|
||||||
|
? decryptedTempPath.substring(dotIndex).toLowerCase()
|
||||||
|
: '.flac';
|
||||||
|
final allowedExt = <String>{'.flac', '.m4a', '.mp3', '.opus'};
|
||||||
|
final finalExt = allowedExt.contains(decryptedExt)
|
||||||
|
? decryptedExt
|
||||||
|
: '.flac';
|
||||||
|
|
||||||
|
final newFileName = '${safBaseName ?? 'track'}$finalExt';
|
||||||
|
final newUri = await _writeTempToSaf(
|
||||||
|
treeUri: settings.downloadTreeUri,
|
||||||
|
relativeDir: effectiveOutputDir,
|
||||||
|
fileName: newFileName,
|
||||||
|
mimeType: _mimeTypeForExt(finalExt),
|
||||||
|
srcPath: decryptedTempPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newUri == null) {
|
||||||
|
_log.e('Failed to write decrypted Amazon stream back to SAF');
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.failed,
|
||||||
|
error: 'Failed to write decrypted file to storage',
|
||||||
|
errorType: DownloadErrorType.unknown,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newUri != currentFilePath) {
|
||||||
|
await _deleteSafFile(currentFilePath);
|
||||||
|
}
|
||||||
|
filePath = newUri;
|
||||||
|
finalSafFileName = newFileName;
|
||||||
|
_log.i('Amazon SAF decryption completed');
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await File(tempPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
if (decryptedTempPath != null && decryptedTempPath != tempPath) {
|
||||||
|
try {
|
||||||
|
await File(decryptedTempPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final decryptedPath = await FFmpegService.decryptAudioFile(
|
||||||
|
inputPath: filePath,
|
||||||
|
decryptionKey: decryptionKey,
|
||||||
|
deleteOriginal: true,
|
||||||
|
);
|
||||||
|
if (decryptedPath == null) {
|
||||||
|
_log.e('FFmpeg decrypt failed for local file');
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.failed,
|
||||||
|
error: 'Failed to decrypt Amazon stream',
|
||||||
|
errorType: DownloadErrorType.unknown,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await deleteFile(filePath);
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filePath = decryptedPath;
|
||||||
|
_log.i('Amazon local decryption completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final isContentUriPath = filePath != null && isContentUri(filePath);
|
final isContentUriPath = filePath != null && isContentUri(filePath);
|
||||||
final mimeType = isContentUriPath
|
final mimeType = isContentUriPath
|
||||||
? await _getSafMimeType(filePath)
|
? await _getSafMimeType(filePath)
|
||||||
@@ -3363,18 +3593,54 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
await File(tempPath).delete();
|
await File(tempPath).delete();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (!isContentUriPath &&
|
||||||
|
!effectiveSafMode &&
|
||||||
|
isFlacFile &&
|
||||||
|
!wasExisting &&
|
||||||
|
actualService == 'amazon' &&
|
||||||
|
decryptionKey.isNotEmpty) {
|
||||||
|
_log.d(
|
||||||
|
'Local FLAC after Amazon decrypt detected, embedding metadata and cover...',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.downloading,
|
||||||
|
progress: 0.99,
|
||||||
|
);
|
||||||
|
|
||||||
|
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||||
|
trackToDownload,
|
||||||
|
result,
|
||||||
|
normalizedAlbumArtist,
|
||||||
|
);
|
||||||
|
final backendGenre = result['genre'] as String?;
|
||||||
|
final backendLabel = result['label'] as String?;
|
||||||
|
final backendCopyright = result['copyright'] as String?;
|
||||||
|
|
||||||
|
await _embedMetadataAndCover(
|
||||||
|
filePath,
|
||||||
|
finalTrack,
|
||||||
|
genre: backendGenre ?? genre,
|
||||||
|
label: backendLabel ?? label,
|
||||||
|
copyright: backendCopyright,
|
||||||
|
);
|
||||||
|
_log.d('Local FLAC metadata embedding completed');
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Local FLAC metadata embedding failed: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt
|
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt
|
||||||
if (!wasExisting &&
|
if (!wasExisting && item.service == 'youtube' && filePath != null) {
|
||||||
item.service == 'youtube' &&
|
|
||||||
filePath != null) {
|
|
||||||
final isOpusFile = filePath.endsWith('.opus');
|
final isOpusFile = filePath.endsWith('.opus');
|
||||||
final isMp3File = filePath.endsWith('.mp3');
|
final isMp3File = filePath.endsWith('.mp3');
|
||||||
|
|
||||||
if (isOpusFile || isMp3File) {
|
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(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.downloading,
|
DownloadStatus.downloading,
|
||||||
@@ -3752,15 +4018,29 @@ final downloadQueueProvider =
|
|||||||
|
|
||||||
class DownloadQueueLookup {
|
class DownloadQueueLookup {
|
||||||
final Map<String, DownloadItem> byTrackId;
|
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) {
|
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) {
|
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 _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||||
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
||||||
|
final _prefs = SharedPreferences.getInstance();
|
||||||
|
|
||||||
class LocalLibraryState {
|
class LocalLibraryState {
|
||||||
final List<LocalLibraryItem> items;
|
final List<LocalLibraryItem> items;
|
||||||
@@ -24,9 +25,9 @@ class LocalLibraryState {
|
|||||||
final bool scanWasCancelled;
|
final bool scanWasCancelled;
|
||||||
final DateTime? lastScannedAt;
|
final DateTime? lastScannedAt;
|
||||||
final int excludedDownloadedCount;
|
final int excludedDownloadedCount;
|
||||||
final Set<String> _isrcSet;
|
|
||||||
final Set<String> _trackKeySet;
|
final Set<String> _trackKeySet;
|
||||||
final Map<String, LocalLibraryItem> _byIsrc;
|
final Map<String, LocalLibraryItem> _byIsrc;
|
||||||
|
final Map<String, LocalLibraryItem> _byTrackKey;
|
||||||
|
|
||||||
LocalLibraryState({
|
LocalLibraryState({
|
||||||
this.items = const [],
|
this.items = const [],
|
||||||
@@ -39,18 +40,22 @@ class LocalLibraryState {
|
|||||||
this.scanWasCancelled = false,
|
this.scanWasCancelled = false,
|
||||||
this.lastScannedAt,
|
this.lastScannedAt,
|
||||||
this.excludedDownloadedCount = 0,
|
this.excludedDownloadedCount = 0,
|
||||||
}) : _isrcSet = items
|
Set<String>? trackKeySet,
|
||||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
Map<String, LocalLibraryItem>? byIsrc,
|
||||||
.map((item) => item.isrc!)
|
Map<String, LocalLibraryItem>? byTrackKey,
|
||||||
.toSet(),
|
}) : _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(),
|
||||||
_trackKeySet = items.map((item) => item.matchKey).toSet(),
|
_byIsrc =
|
||||||
_byIsrc = Map.fromEntries(
|
byIsrc ??
|
||||||
items
|
Map.fromEntries(
|
||||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
items
|
||||||
.map((item) => MapEntry(item.isrc!, item)),
|
.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) {
|
bool hasTrack(String trackName, String artistName) {
|
||||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||||
@@ -61,7 +66,7 @@ class LocalLibraryState {
|
|||||||
|
|
||||||
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
|
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
|
||||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
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}) {
|
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
||||||
@@ -86,8 +91,11 @@ class LocalLibraryState {
|
|||||||
DateTime? lastScannedAt,
|
DateTime? lastScannedAt,
|
||||||
int? excludedDownloadedCount,
|
int? excludedDownloadedCount,
|
||||||
}) {
|
}) {
|
||||||
|
final nextItems = items ?? this.items;
|
||||||
|
final keepDerivedIndex = identical(nextItems, this.items);
|
||||||
|
|
||||||
return LocalLibraryState(
|
return LocalLibraryState(
|
||||||
items: items ?? this.items,
|
items: nextItems,
|
||||||
isScanning: isScanning ?? this.isScanning,
|
isScanning: isScanning ?? this.isScanning,
|
||||||
scanProgress: scanProgress ?? this.scanProgress,
|
scanProgress: scanProgress ?? this.scanProgress,
|
||||||
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
||||||
@@ -98,6 +106,9 @@ class LocalLibraryState {
|
|||||||
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
||||||
excludedDownloadedCount:
|
excludedDownloadedCount:
|
||||||
excludedDownloadedCount ?? this.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 _isLoaded = false;
|
||||||
bool _scanCancelRequested = false;
|
bool _scanCancelRequested = false;
|
||||||
int _progressPollingErrorCount = 0;
|
int _progressPollingErrorCount = 0;
|
||||||
|
bool _isProgressPollingInFlight = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
LocalLibraryState build() {
|
LocalLibraryState build() {
|
||||||
@@ -128,13 +140,17 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_isLoaded = true;
|
_isLoaded = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final jsonList = await _db.getAll();
|
final dbItemsFuture = _db.getAll();
|
||||||
final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
final prefsFuture = _prefs;
|
||||||
|
final jsonList = await dbItemsFuture;
|
||||||
|
final items = jsonList
|
||||||
|
.map((e) => LocalLibraryItem.fromJson(e))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
DateTime? lastScannedAt;
|
DateTime? lastScannedAt;
|
||||||
var excludedDownloadedCount = 0;
|
var excludedDownloadedCount = 0;
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await prefsFuture;
|
||||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
||||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
||||||
@@ -395,16 +411,37 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
void _startProgressPolling() {
|
void _startProgressPolling() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
||||||
|
if (_isProgressPollingInFlight) return;
|
||||||
|
_isProgressPollingInFlight = true;
|
||||||
try {
|
try {
|
||||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||||
|
final nextProgress =
|
||||||
state = state.copyWith(
|
(progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||||
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
|
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||||
scanCurrentFile: progress['current_file'] as String?,
|
0.0,
|
||||||
scanTotalFiles: progress['total_files'] as int? ?? 0,
|
100.0,
|
||||||
scannedFiles: progress['scanned_files'] as int? ?? 0,
|
|
||||||
scanErrorCount: progress['error_count'] as int? ?? 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) {
|
if (progress['is_complete'] == true) {
|
||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
@@ -415,6 +452,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
if (_progressPollingErrorCount <= 3) {
|
if (_progressPollingErrorCount <= 3) {
|
||||||
_log.w('Library scan progress polling failed: $e');
|
_log.w('Library scan progress polling failed: $e');
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
_isProgressPollingInFlight = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -423,6 +462,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = null;
|
_progressTimer = null;
|
||||||
_progressPollingErrorCount = 0;
|
_progressPollingErrorCount = 0;
|
||||||
|
_isProgressPollingInFlight = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelScan() async {
|
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>{};
|
final backfilled = <String, int>{};
|
||||||
for (final path in legacyPaths) {
|
|
||||||
if (_scanCancelRequested || path.startsWith('content://')) {
|
for (var i = 0; i < paths.length; i += chunkSize) {
|
||||||
continue;
|
if (_scanCancelRequested) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
try {
|
final end = (i + chunkSize < paths.length) ? i + chunkSize : paths.length;
|
||||||
final stat = await File(path).stat();
|
final chunk = paths.sublist(i, end);
|
||||||
if (stat.type == FileSystemEntityType.file) {
|
final chunkEntries = await Future.wait<MapEntry<String, int>?>(
|
||||||
backfilled[path] = stat.modified.millisecondsSinceEpoch;
|
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;
|
return backfilled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,6 +231,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setUsePrimaryArtistOnly(bool enabled) {
|
||||||
|
state = state.copyWith(usePrimaryArtistOnly: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setHistoryViewMode(String mode) {
|
void setHistoryViewMode(String mode) {
|
||||||
state = state.copyWith(historyViewMode: mode);
|
state = state.copyWith(historyViewMode: mode);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
+243
-110
@@ -26,8 +26,10 @@ class TrackState {
|
|||||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
||||||
final bool hasSearchText; // For back button handling
|
final bool hasSearchText; // For back button handling
|
||||||
final bool isShowingRecentAccess; // For recent access mode
|
final bool isShowingRecentAccess; // For recent access mode
|
||||||
final String? searchExtensionId; // Extension ID used for current search results
|
final String?
|
||||||
final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
searchExtensionId; // Extension ID used for current search results
|
||||||
|
final String?
|
||||||
|
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
@@ -52,7 +54,12 @@ class TrackState {
|
|||||||
this.selectedSearchFilter,
|
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({
|
TrackState copyWith({
|
||||||
List<Track>? tracks,
|
List<Track>? tracks,
|
||||||
@@ -95,9 +102,12 @@ class TrackState {
|
|||||||
searchAlbums: searchAlbums ?? this.searchAlbums,
|
searchAlbums: searchAlbums ?? this.searchAlbums,
|
||||||
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
|
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
|
||||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
isShowingRecentAccess:
|
||||||
|
isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||||
searchExtensionId: searchExtensionId,
|
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> {
|
class TrackNotifier extends Notifier<TrackState> {
|
||||||
int _currentRequestId = 0;
|
int _currentRequestId = 0;
|
||||||
|
static const int _maxPreWarmTracksPerRequest = 80;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TrackState build() {
|
TrackState build() {
|
||||||
@@ -197,39 +208,42 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||||
if (extensionHandler != null) {
|
if (extensionHandler != null) {
|
||||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||||
|
|
||||||
// Retry logic for extension URL handlers (up to 3 attempts)
|
// Retry logic for extension URL handlers (up to 3 attempts)
|
||||||
Map<String, dynamic>? result;
|
Map<String, dynamic>? result;
|
||||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||||
result = await PlatformBridge.handleURLWithExtension(url);
|
result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
// Check if we got valid data
|
// 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 trackData = result['track'] as Map<String, dynamic>;
|
||||||
final name = trackData['name']?.toString() ?? '';
|
final name = trackData['name']?.toString() ?? '';
|
||||||
if (name.isNotEmpty) {
|
if (name.isNotEmpty) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if (result != null && (result['type'] == 'album' || result['type'] == 'playlist')) {
|
} else if (result != null &&
|
||||||
|
(result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||||
break;
|
break;
|
||||||
} else if (result != null && result['type'] == 'artist') {
|
} else if (result != null && result['type'] == 'artist') {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt < 3) {
|
if (attempt < 3) {
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
final type = result['type'] as String?;
|
final type = result['type'] as String?;
|
||||||
final extensionId = result['extension_id'] as String?;
|
final extensionId = result['extension_id'] as String?;
|
||||||
|
|
||||||
if (type == 'track' && result['track'] != null) {
|
if (type == 'track' && result['track'] != null) {
|
||||||
final trackData = result['track'] as Map<String, dynamic>;
|
final trackData = result['track'] as Map<String, dynamic>;
|
||||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||||
|
|
||||||
if (track.name.isEmpty) {
|
if (track.name.isEmpty) {
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -237,7 +251,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [track],
|
tracks: [track],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -245,15 +259,27 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
searchExtensionId: extensionId,
|
searchExtensionId: extensionId,
|
||||||
);
|
);
|
||||||
return;
|
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 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(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumId: result['album']?['id'] as String?,
|
albumId: result['album']?['id'] as String?,
|
||||||
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
|
albumName:
|
||||||
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
result['name'] as String? ??
|
||||||
|
result['album']?['name'] as String?,
|
||||||
|
playlistName: type == 'playlist'
|
||||||
|
? result['name'] as String?
|
||||||
|
: null,
|
||||||
coverUrl: result['cover_url'] as String?,
|
coverUrl: result['cover_url'] as String?,
|
||||||
searchExtensionId: extensionId,
|
searchExtensionId: extensionId,
|
||||||
);
|
);
|
||||||
@@ -261,17 +287,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'artist' && result['artist'] != null) {
|
} else if (type == 'artist' && result['artist'] != null) {
|
||||||
final artistData = result['artist'] as Map<String, dynamic>;
|
final artistData = result['artist'] as Map<String, dynamic>;
|
||||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
final albumsList = artistData['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>))
|
||||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
.toList();
|
||||||
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).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(
|
state = TrackState(
|
||||||
tracks: [],
|
tracks: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
artistId: artistData['id'] as String?,
|
artistId: artistData['id'] as String?,
|
||||||
artistName: artistData['name'] 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?,
|
headerImageUrl: artistData['header_image'] as String?,
|
||||||
monthlyListeners: artistData['listeners'] as int?,
|
monthlyListeners: artistData['listeners'] as int?,
|
||||||
artistAlbums: albums,
|
artistAlbums: albums,
|
||||||
@@ -282,19 +320,19 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Try Deezer URL parsing
|
// Step 2: Try Deezer URL parsing
|
||||||
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
|
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
|
||||||
_log.i('Detected Deezer URL, parsing...');
|
_log.i('Detected Deezer URL, parsing...');
|
||||||
final parsed = await PlatformBridge.parseDeezerUrl(url);
|
final parsed = await PlatformBridge.parseDeezerUrl(url);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
final id = parsed['id'] as String;
|
final id = parsed['id'] as String;
|
||||||
|
|
||||||
final metadata = await PlatformBridge.getDeezerMetadata(type, id);
|
final metadata = await PlatformBridge.getDeezerMetadata(type, id);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
final track = _parseTrack(trackData);
|
final track = _parseTrack(trackData);
|
||||||
@@ -306,7 +344,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'album') {
|
} else if (type == 'album') {
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -316,9 +356,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
);
|
);
|
||||||
_preWarmCacheForTracks(tracks);
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'playlist') {
|
} 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 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(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -329,7 +372,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
final albumsList = metadata['albums'] as List<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(
|
state = TrackState(
|
||||||
tracks: [],
|
tracks: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -341,33 +386,38 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Try Tidal URL parsing
|
// Step 3: Try Tidal URL parsing
|
||||||
if (url.contains('tidal.com')) {
|
if (url.contains('tidal.com')) {
|
||||||
_log.i('Detected Tidal URL, parsing...');
|
_log.i('Detected Tidal URL, parsing...');
|
||||||
final parsed = await PlatformBridge.parseTidalUrl(url);
|
final parsed = await PlatformBridge.parseTidalUrl(url);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
final id = parsed['id'] as String;
|
final id = parsed['id'] as String;
|
||||||
|
|
||||||
_log.i('Tidal URL parsed: type=$type, id=$id');
|
_log.i('Tidal URL parsed: type=$type, id=$id');
|
||||||
|
|
||||||
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
|
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
|
||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
try {
|
try {
|
||||||
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
|
_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;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final spotifyUrl = conversion['spotify_url'] as String?;
|
final spotifyUrl = conversion['spotify_url'] as String?;
|
||||||
final deezerUrl = conversion['deezer_url'] as String?;
|
final deezerUrl = conversion['deezer_url'] as String?;
|
||||||
|
|
||||||
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
|
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
|
||||||
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
|
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(spotifyUrl);
|
final metadata =
|
||||||
|
await PlatformBridge.getSpotifyMetadataWithFallback(
|
||||||
|
spotifyUrl,
|
||||||
|
);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
final track = _parseTrack(trackData);
|
final track = _parseTrack(trackData);
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
@@ -378,10 +428,15 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
return;
|
return;
|
||||||
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
|
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
|
||||||
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
|
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
|
||||||
final deezerParsed = await PlatformBridge.parseDeezerUrl(deezerUrl);
|
final deezerParsed = await PlatformBridge.parseDeezerUrl(
|
||||||
final metadata = await PlatformBridge.getDeezerMetadata('track', deezerParsed['id'] as String);
|
deezerUrl,
|
||||||
|
);
|
||||||
|
final metadata = await PlatformBridge.getDeezerMetadata(
|
||||||
|
'track',
|
||||||
|
deezerParsed['id'] as String,
|
||||||
|
);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
final track = _parseTrack(trackData);
|
final track = _parseTrack(trackData);
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
@@ -395,30 +450,31 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
_log.w('Failed to convert Tidal URL via SongLink: $e');
|
_log.w('Failed to convert Tidal URL via SongLink: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For album/artist/playlist, not yet supported
|
// For album/artist/playlist, not yet supported
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
isLoading: false,
|
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,
|
hasSearchText: state.hasSearchText,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Fall back to Spotify parsing
|
// Step 4: Fall back to Spotify parsing
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
|
|
||||||
Map<String, dynamic> metadata;
|
Map<String, dynamic> metadata;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
@@ -432,7 +488,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'album') {
|
} else if (type == 'album') {
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -444,7 +502,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'playlist') {
|
} 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 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>?;
|
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -456,7 +516,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
final albumsList = metadata['albums'] as List<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(
|
state = TrackState(
|
||||||
tracks: [],
|
tracks: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -468,17 +530,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
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;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve selected filter during loading
|
// Preserve selected filter during loading
|
||||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||||
|
|
||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
state = TrackState(
|
||||||
|
isLoading: true,
|
||||||
|
hasSearchText: state.hasSearchText,
|
||||||
|
selectedSearchFilter: currentFilter,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
@@ -494,20 +568,23 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
searchProvider.isNotEmpty;
|
searchProvider.isNotEmpty;
|
||||||
|
|
||||||
final source = metadataSource ?? 'deezer';
|
final source = metadataSource ?? 'deezer';
|
||||||
|
|
||||||
_log.i(
|
_log.i(
|
||||||
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
|
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> results;
|
Map<String, dynamic> results;
|
||||||
List<Track> extensionTracks = [];
|
List<Track> extensionTracks = [];
|
||||||
|
|
||||||
if (useExtensions) {
|
if (useExtensions) {
|
||||||
try {
|
try {
|
||||||
_log.d('Calling extension search API...');
|
_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');
|
_log.i('Extensions returned ${extResults.length} tracks');
|
||||||
|
|
||||||
for (final t in extResults) {
|
for (final t in extResults) {
|
||||||
try {
|
try {
|
||||||
extensionTracks.add(_parseSearchTrack(t));
|
extensionTracks.add(_parseSearchTrack(t));
|
||||||
@@ -519,37 +596,52 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
_log.w('Extension search failed, falling back to built-in: $e');
|
_log.w('Extension search failed, falling back to built-in: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source == 'deezer') {
|
if (source == 'deezer') {
|
||||||
_log.d('Calling Deezer search API...');
|
_log.d('Calling Deezer search API...');
|
||||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 2, filter: currentFilter);
|
results = await PlatformBridge.searchDeezerAll(
|
||||||
_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');
|
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 {
|
} else {
|
||||||
_log.d('Calling Spotify search API...');
|
_log.d('Calling Spotify search API...');
|
||||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 2);
|
results = await PlatformBridge.searchSpotifyAll(
|
||||||
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
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)) {
|
if (!_isRequestValid(requestId)) {
|
||||||
_log.w('Search request cancelled (requestId=$requestId)');
|
_log.w('Search request cancelled (requestId=$requestId)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||||
final albumList = results['albums'] 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>[];
|
final tracks = <Track>[];
|
||||||
|
|
||||||
tracks.addAll(extensionTracks);
|
tracks.addAll(extensionTracks);
|
||||||
|
|
||||||
final existingIsrcs = extensionTracks
|
final existingIsrcs = extensionTracks
|
||||||
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
||||||
.map((t) => t.isrc!)
|
.map((t) => t.isrc!)
|
||||||
.toSet();
|
.toSet();
|
||||||
|
|
||||||
for (int i = 0; i < trackList.length; i++) {
|
for (int i = 0; i < trackList.length; i++) {
|
||||||
final t = trackList[i];
|
final t = trackList[i];
|
||||||
try {
|
try {
|
||||||
@@ -566,7 +658,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
_log.e('Failed to parse track[$i]: $e', e);
|
_log.e('Failed to parse track[$i]: $e', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final artists = <SearchArtist>[];
|
final artists = <SearchArtist>[];
|
||||||
for (int i = 0; i < artistList.length; i++) {
|
for (int i = 0; i < artistList.length; i++) {
|
||||||
final a = artistList[i];
|
final a = artistList[i];
|
||||||
@@ -580,7 +672,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
_log.e('Failed to parse artist[$i]: $e', e);
|
_log.e('Failed to parse artist[$i]: $e', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final albums = <SearchAlbum>[];
|
final albums = <SearchAlbum>[];
|
||||||
for (int i = 0; i < albumList.length; i++) {
|
for (int i = 0; i < albumList.length; i++) {
|
||||||
final a = albumList[i];
|
final a = albumList[i];
|
||||||
@@ -594,7 +686,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
_log.e('Failed to parse album[$i]: $e', e);
|
_log.e('Failed to parse album[$i]: $e', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final playlistList = results['playlists'] as List<dynamic>? ?? [];
|
final playlistList = results['playlists'] as List<dynamic>? ?? [];
|
||||||
final playlists = <SearchPlaylist>[];
|
final playlists = <SearchPlaylist>[];
|
||||||
for (int i = 0; i < playlistList.length; i++) {
|
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.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(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
searchArtists: artists,
|
searchArtists: artists,
|
||||||
@@ -624,31 +718,45 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
_log.e('Search failed: $e', e, stackTrace);
|
_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;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading
|
selectedSearchFilter:
|
||||||
|
state.selectedSearchFilter, // Preserve filter during loading
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_log.i('Custom search started: extension=$extensionId, query="$query"');
|
_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)) {
|
if (!_isRequestValid(requestId)) {
|
||||||
_log.w('Custom search request cancelled (requestId=$requestId)');
|
_log.w('Custom search request cancelled (requestId=$requestId)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('Custom search returned ${results.length} tracks');
|
_log.i('Custom search returned ${results.length} tracks');
|
||||||
|
|
||||||
final tracks = <Track>[];
|
final tracks = <Track>[];
|
||||||
for (int i = 0; i < results.length; i++) {
|
for (int i = 0; i < results.length; i++) {
|
||||||
final t = results[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.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(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
searchArtists: [],
|
searchArtists: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
searchExtensionId: extensionId, // Store which extension was used
|
searchExtensionId: extensionId, // Store which extension was used
|
||||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter
|
selectedSearchFilter:
|
||||||
|
state.selectedSearchFilter, // Preserve selected filter
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
_log.e('Custom search failed: $e', e, stackTrace);
|
_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;
|
if (track.isrc == null || track.isrc!.isEmpty) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final availability = await PlatformBridge.checkAvailability(track.id, track.isrc!);
|
final availability = await PlatformBridge.checkAvailability(
|
||||||
|
track.id,
|
||||||
|
track.isrc!,
|
||||||
|
);
|
||||||
final updatedTrack = Track(
|
final updatedTrack = Track(
|
||||||
id: track.id,
|
id: track.id,
|
||||||
name: track.name,
|
name: track.name,
|
||||||
@@ -736,11 +854,14 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
state = state.copyWith(hasSearchText: hasText);
|
state = state.copyWith(hasSearchText: hasText);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setShowingRecentAccess(bool showing) {
|
void setShowingRecentAccess(bool showing) {
|
||||||
|
if (state.isShowingRecentAccess == showing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
state = state.copyWith(isShowingRecentAccess: showing);
|
state = state.copyWith(isShowingRecentAccess: showing);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set tracks from a collection (album/playlist) opened from search results
|
/// Set tracks from a collection (album/playlist) opened from search results
|
||||||
void setTracksFromCollection({
|
void setTracksFromCollection({
|
||||||
required List<Track> tracks,
|
required List<Track> tracks,
|
||||||
@@ -782,9 +903,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (durationValue is double) {
|
} else if (durationValue is double) {
|
||||||
durationMs = durationValue.toInt();
|
durationMs = durationValue.toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
final itemType = data['item_type']?.toString();
|
final itemType = data['item_type']?.toString();
|
||||||
|
|
||||||
return Track(
|
return Track(
|
||||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||||
name: (data['name'] ?? '').toString(),
|
name: (data['name'] ?? '').toString(),
|
||||||
@@ -797,7 +918,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
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(),
|
albumType: data['album_type']?.toString(),
|
||||||
itemType: itemType,
|
itemType: itemType,
|
||||||
);
|
);
|
||||||
@@ -849,16 +973,25 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
if (tracks.isEmpty) return;
|
||||||
if (tracksWithIsrc.isEmpty) return;
|
final cacheRequests = <Map<String, String>>[];
|
||||||
|
for (final track in tracks) {
|
||||||
final cacheRequests = tracksWithIsrc.map((t) => {
|
final isrc = track.isrc;
|
||||||
'isrc': t.isrc!,
|
if (isrc == null || isrc.isEmpty) {
|
||||||
'track_name': t.name,
|
continue;
|
||||||
'artist_name': t.artistName,
|
}
|
||||||
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
cacheRequests.add({
|
||||||
'service': 'tidal',
|
'isrc': isrc,
|
||||||
}).toList();
|
'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((_) {});
|
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,6 +268,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
(constraints.maxHeight - kToolbarHeight) /
|
(constraints.maxHeight - kToolbarHeight) /
|
||||||
(expandedHeight - kToolbarHeight);
|
(expandedHeight - kToolbarHeight);
|
||||||
final showContent = collapseRatio > 0.3;
|
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(
|
return FlexibleSpaceBar(
|
||||||
collapseMode: CollapseMode.none,
|
collapseMode: CollapseMode.none,
|
||||||
@@ -279,6 +286,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: widget.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: backgroundMemCacheWidth,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
placeholder: (_, _) =>
|
placeholder: (_, _) =>
|
||||||
Container(color: colorScheme.surface),
|
Container(color: colorScheme.surface),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||||
|
|
||||||
/// Screen to display downloaded tracks from a specific album
|
/// Screen to display downloaded tracks from a specific album
|
||||||
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||||
@@ -32,6 +34,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
final ScrollController _scrollController = ScrollController();
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -46,6 +62,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
super.dispose();
|
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() {
|
void _onScroll() {
|
||||||
final shouldShow = _scrollController.offset > 280;
|
final shouldShow = _scrollController.offset > 280;
|
||||||
if (shouldShow != _showTitleInAppBar) {
|
if (shouldShow != _showTitleInAppBar) {
|
||||||
@@ -57,41 +84,74 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
List<DownloadHistoryItem> _getAlbumTracks(
|
List<DownloadHistoryItem> _getAlbumTracks(
|
||||||
List<DownloadHistoryItem> allItems,
|
List<DownloadHistoryItem> allItems,
|
||||||
) {
|
) {
|
||||||
return allItems.where((item) {
|
final cached = _albumTracksCache;
|
||||||
// Use albumArtist if available and not empty, otherwise artistName
|
if (cached != null && identical(allItems, _albumTracksSourceCache)) {
|
||||||
final itemArtist =
|
return cached;
|
||||||
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
}
|
||||||
? item.albumArtist!
|
|
||||||
: item.artistName;
|
final tracks =
|
||||||
// Use lowercase for case-insensitive matching
|
allItems.where((item) {
|
||||||
final itemKey =
|
// Use albumArtist if available and not empty, otherwise artistName
|
||||||
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
final itemArtist =
|
||||||
final albumKey =
|
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||||
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
? item.albumArtist!
|
||||||
return itemKey == albumKey;
|
: item.artistName;
|
||||||
}).toList()..sort((a, b) {
|
// Use lowercase for case-insensitive matching
|
||||||
// Sort by disc number first, then by track number
|
final itemKey =
|
||||||
final aDisc = a.discNumber ?? 1;
|
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||||
final bDisc = b.discNumber ?? 1;
|
return itemKey == _albumLookupKey;
|
||||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
}).toList()..sort((a, b) {
|
||||||
final aNum = a.trackNumber ?? 999;
|
// Sort by disc number first, then by track number
|
||||||
final bNum = b.trackNumber ?? 999;
|
final aDisc = a.discNumber ?? 1;
|
||||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
final bDisc = b.discNumber ?? 1;
|
||||||
return a.trackName.compareTo(b.trackName);
|
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,
|
List<DownloadHistoryItem> tracks,
|
||||||
) {
|
) {
|
||||||
|
final cached = _discGroupingCache;
|
||||||
|
if (cached != null && identical(tracks, _discGroupingSourceCache)) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
final discMap = <int, List<DownloadHistoryItem>>{};
|
final discMap = <int, List<DownloadHistoryItem>>{};
|
||||||
for (final track in tracks) {
|
for (final track in tracks) {
|
||||||
final discNumber = track.discNumber ?? 1;
|
final discNumber = track.discNumber ?? 1;
|
||||||
discMap.putIfAbsent(discNumber, () => []).add(track);
|
discMap.putIfAbsent(discNumber, () => []).add(track);
|
||||||
}
|
}
|
||||||
|
_discGroupingSourceCache = tracks;
|
||||||
|
_discGroupingCache = discMap;
|
||||||
|
_sortedDiscNumbersCache = discMap.keys.toList()..sort();
|
||||||
return discMap;
|
return discMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<int> _getSortedDiscNumbers(List<DownloadHistoryItem> tracks) {
|
||||||
|
_getDiscGroups(tracks);
|
||||||
|
return _sortedDiscNumbersCache ?? const [];
|
||||||
|
}
|
||||||
|
|
||||||
void _enterSelectionMode(String itemId) {
|
void _enterSelectionMode(String itemId) {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -152,10 +212,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||||
final idsToDelete = _selectedIds.toList();
|
final idsToDelete = _selectedIds.toList();
|
||||||
|
final tracksById = {for (final track in currentTracks) track.id: track};
|
||||||
|
|
||||||
int deletedCount = 0;
|
int deletedCount = 0;
|
||||||
for (final id in idsToDelete) {
|
for (final id in idsToDelete) {
|
||||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
final item = tracksById[id];
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
try {
|
try {
|
||||||
await deleteFile(item.filePath);
|
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);
|
_precacheCover(item.coverUrl);
|
||||||
Navigator.push(
|
final beforeModTime =
|
||||||
context,
|
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
|
||||||
|
item.filePath,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final result = await navigator.push(
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
@@ -204,6 +283,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||||
|
item.filePath,
|
||||||
|
beforeModTime: beforeModTime,
|
||||||
|
force: result == true,
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _precacheCover(String? url) {
|
void _precacheCover(String? url) {
|
||||||
@@ -211,8 +296,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||||
precacheImage(
|
precacheImage(
|
||||||
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
|
ResizeImage(
|
||||||
|
CachedNetworkImageProvider(
|
||||||
|
url,
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
),
|
||||||
|
width: targetSize,
|
||||||
|
height: targetSize,
|
||||||
|
),
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -256,7 +352,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
CustomScrollView(
|
CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
_buildAppBar(context, colorScheme),
|
_buildAppBar(context, colorScheme, tracks),
|
||||||
_buildInfoCard(context, colorScheme, tracks),
|
_buildInfoCard(context, colorScheme, tracks),
|
||||||
_buildTrackListHeader(context, colorScheme, tracks),
|
_buildTrackListHeader(context, colorScheme, tracks),
|
||||||
_buildTrackList(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 mediaSize = MediaQuery.of(context).size;
|
||||||
final screenWidth = mediaSize.width;
|
final screenWidth = mediaSize.width;
|
||||||
final shortestSide = mediaSize.shortestSide;
|
final shortestSide = mediaSize.shortestSide;
|
||||||
@@ -294,6 +415,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||||
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||||
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||||
|
final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks);
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: expandedHeight,
|
expandedHeight: expandedHeight,
|
||||||
@@ -322,6 +444,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
(constraints.maxHeight - kToolbarHeight) /
|
(constraints.maxHeight - kToolbarHeight) /
|
||||||
(expandedHeight - kToolbarHeight);
|
(expandedHeight - kToolbarHeight);
|
||||||
final showContent = collapseRatio > 0.3;
|
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(
|
return FlexibleSpaceBar(
|
||||||
collapseMode: CollapseMode.none,
|
collapseMode: CollapseMode.none,
|
||||||
@@ -329,10 +458,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Blurred cover background
|
// 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(
|
CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: widget.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: backgroundMemCacheWidth,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
placeholder: (_, _) =>
|
placeholder: (_, _) =>
|
||||||
Container(color: colorScheme.surface),
|
Container(color: colorScheme.surface),
|
||||||
@@ -389,7 +527,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(20),
|
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(
|
? CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: widget.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
@@ -437,6 +590,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
List<DownloadHistoryItem> tracks,
|
List<DownloadHistoryItem> tracks,
|
||||||
) {
|
) {
|
||||||
|
final commonQuality = _getCommonQuality(tracks);
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -500,22 +655,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (_getCommonQuality(tracks) != null)
|
if (commonQuality != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12,
|
horizontal: 12,
|
||||||
vertical: 6,
|
vertical: 6,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
color: commonQuality.startsWith('24')
|
||||||
? colorScheme.tertiaryContainer
|
? colorScheme.tertiaryContainer
|
||||||
: colorScheme.surfaceContainerHighest,
|
: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_getCommonQuality(tracks)!,
|
commonQuality,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
color: commonQuality.startsWith('24')
|
||||||
? colorScheme.onTertiaryContainer
|
? colorScheme.onTertiaryContainer
|
||||||
: colorScheme.onSurfaceVariant,
|
: colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -534,12 +689,30 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
|
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
|
||||||
if (tracks.isEmpty) return null;
|
if (identical(tracks, _commonQualitySourceCache)) {
|
||||||
final firstQuality = tracks.first.quality;
|
return _commonQualityCache;
|
||||||
if (firstQuality == null) return null;
|
|
||||||
for (final track in tracks) {
|
|
||||||
if (track.quality != firstQuality) return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return firstQuality;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +758,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
List<DownloadHistoryItem> tracks,
|
List<DownloadHistoryItem> tracks,
|
||||||
) {
|
) {
|
||||||
final discMap = _groupTracksByDisc(tracks);
|
final discMap = _getDiscGroups(tracks);
|
||||||
|
|
||||||
if (discMap.length <= 1) {
|
if (discMap.length <= 1) {
|
||||||
return SliverList(
|
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 = [];
|
final List<Widget> children = [];
|
||||||
|
|
||||||
for (final discNumber in discNumbers) {
|
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/album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||||
import 'package:spotiflac_android/services/csv_import_service.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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
@@ -34,16 +35,25 @@ class HomeTab extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _RecentAccessView {
|
class _RecentAccessView {
|
||||||
final List<RecentAccessItem> uniqueItems;
|
final List<RecentAccessItem> uniqueItems;
|
||||||
final List<RecentAccessItem> downloadItems;
|
final List<String> downloadIds;
|
||||||
|
final Map<String, String> downloadFilePathByRecentKey;
|
||||||
final bool hasHiddenDownloads;
|
final bool hasHiddenDownloads;
|
||||||
|
|
||||||
const _RecentAccessView({
|
const _RecentAccessView({
|
||||||
required this.uniqueItems,
|
required this.uniqueItems,
|
||||||
required this.downloadItems,
|
required this.downloadIds,
|
||||||
|
required this.downloadFilePathByRecentKey,
|
||||||
required this.hasHiddenDownloads,
|
required this.hasHiddenDownloads,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RecentAlbumAggregate {
|
||||||
|
int count;
|
||||||
|
DownloadHistoryItem mostRecent;
|
||||||
|
|
||||||
|
_RecentAlbumAggregate({required this.count, required this.mostRecent});
|
||||||
|
}
|
||||||
|
|
||||||
class _CsvImportOptions {
|
class _CsvImportOptions {
|
||||||
final bool confirmed;
|
final bool confirmed;
|
||||||
final bool skipDownloaded;
|
final bool skipDownloaded;
|
||||||
@@ -57,7 +67,6 @@ class _CsvImportOptions {
|
|||||||
class _HomeTabState extends ConsumerState<HomeTab>
|
class _HomeTabState extends ConsumerState<HomeTab>
|
||||||
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
||||||
final _urlController = TextEditingController();
|
final _urlController = TextEditingController();
|
||||||
bool _isTyping = false;
|
|
||||||
final FocusNode _searchFocusNode = FocusNode();
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
String? _lastSearchQuery;
|
String? _lastSearchQuery;
|
||||||
late final ProviderSubscription<TrackState> _trackStateSub;
|
late final ProviderSubscription<TrackState> _trackStateSub;
|
||||||
@@ -74,6 +83,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
List<RecentAccessItem>? _recentAccessItemsCache;
|
List<RecentAccessItem>? _recentAccessItemsCache;
|
||||||
Set<String>? _recentAccessHiddenIdsCache;
|
Set<String>? _recentAccessHiddenIdsCache;
|
||||||
_RecentAccessView? _recentAccessViewCache;
|
_RecentAccessView? _recentAccessViewCache;
|
||||||
|
bool _embeddedCoverRefreshScheduled = false;
|
||||||
|
List<Extension>? _thumbnailSizesExtensionsCache;
|
||||||
|
Map<String, (double, double)>? _thumbnailSizesCache;
|
||||||
|
|
||||||
double _responsiveScale({
|
double _responsiveScale({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
@@ -197,6 +209,27 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
super.dispose();
|
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() {
|
void _onSearchFocusChanged() {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
@@ -214,7 +247,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
_urlController.text.isNotEmpty &&
|
_urlController.text.isNotEmpty &&
|
||||||
!_searchFocusNode.hasFocus) {
|
!_searchFocusNode.hasFocus) {
|
||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
setState(() => _isTyping = false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,10 +269,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
|
|
||||||
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
||||||
|
|
||||||
if (text.isNotEmpty && !_isTyping) {
|
if (text.isEmpty) {
|
||||||
setState(() => _isTyping = true);
|
|
||||||
} else if (text.isEmpty && _isTyping) {
|
|
||||||
setState(() => _isTyping = false);
|
|
||||||
_liveSearchDebounce?.cancel();
|
_liveSearchDebounce?.cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -347,7 +376,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
_lastSearchQuery = null;
|
_lastSearchQuery = null;
|
||||||
setState(() => _isTyping = false);
|
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +415,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
setState(() => _isTyping = false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +440,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
setState(() => _isTyping = false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +461,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
setState(() => _isTyping = false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -778,13 +803,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
final showLocalLibraryIndicator =
|
final showLocalLibraryIndicator =
|
||||||
localLibrarySettings.$1 && localLibrarySettings.$2;
|
localLibrarySettings.$1 && localLibrarySettings.$2;
|
||||||
final thumbnailSizesByExtensionId = <String, (double, double)>{
|
final thumbnailSizesByExtensionId = _getThumbnailSizesByExtensionId(
|
||||||
for (final extension in extensions)
|
extensions,
|
||||||
if (extension.searchBehavior != null)
|
);
|
||||||
extension.id: extension.searchBehavior!.getThumbnailSize(
|
|
||||||
defaultSize: 56,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
Extension? currentSearchExtension;
|
Extension? currentSearchExtension;
|
||||||
List<SearchFilter> searchFilters = [];
|
List<SearchFilter> searchFilters = [];
|
||||||
|
|
||||||
@@ -932,7 +953,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Search filter bar (only shown when has search results)
|
// Search filter bar (only shown when has search results)
|
||||||
if (searchFilters.isNotEmpty && hasActualResults && !showRecentAccess)
|
if (searchFilters.isNotEmpty &&
|
||||||
|
hasActualResults &&
|
||||||
|
!showRecentAccess)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _buildSearchFilterBar(
|
child: _buildSearchFilterBar(
|
||||||
searchFilters,
|
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(
|
Widget _buildRecentDownloads(
|
||||||
List<DownloadHistoryItem> items,
|
List<DownloadHistoryItem> items,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
@@ -1049,6 +1083,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
itemCount: itemCount,
|
itemCount: itemCount,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
|
final embeddedCoverPath = DownloadedEmbeddedCoverResolver.resolve(
|
||||||
|
item.filePath,
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
);
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(item.id),
|
key: ValueKey(item.id),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -1060,7 +1098,26 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
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(
|
? CachedNetworkImage(
|
||||||
imageUrl: item.coverUrl!,
|
imageUrl: item.coverUrl!,
|
||||||
width: coverSize,
|
width: coverSize,
|
||||||
@@ -1115,63 +1172,58 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
final albumGroups = <String, List<DownloadHistoryItem>>{};
|
final albumGroups = <String, _RecentAlbumAggregate>{};
|
||||||
for (final h in historyItems) {
|
for (final h in historyItems) {
|
||||||
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
|
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
|
||||||
? h.albumArtist!
|
? h.albumArtist!
|
||||||
: h.artistName;
|
: h.artistName;
|
||||||
final albumKey = '${h.albumName}|$artistForKey';
|
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>[];
|
final downloadIds = <String>[];
|
||||||
for (final entry in albumGroups.entries) {
|
final visibleDownloads = <RecentAccessItem>[];
|
||||||
final tracks = entry.value;
|
final downloadFilePathByRecentKey = <String, String>{};
|
||||||
final mostRecent = tracks.reduce(
|
for (final aggregate in albumGroups.values) {
|
||||||
(a, b) => a.downloadedAt.isAfter(b.downloadedAt) ? a : b,
|
final mostRecent = aggregate.mostRecent;
|
||||||
);
|
|
||||||
final artistForKey =
|
final artistForKey =
|
||||||
(mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty)
|
(mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty)
|
||||||
? mostRecent.albumArtist!
|
? mostRecent.albumArtist!
|
||||||
: mostRecent.artistName;
|
: mostRecent.artistName;
|
||||||
|
|
||||||
if (tracks.length == 1) {
|
final isSingleTrack = aggregate.count == 1;
|
||||||
downloadItems.add(
|
final recentId = isSingleTrack
|
||||||
RecentAccessItem(
|
? (mostRecent.spotifyId ?? mostRecent.id)
|
||||||
id: mostRecent.spotifyId ?? mostRecent.id,
|
: '${mostRecent.albumName}|$artistForKey';
|
||||||
name: mostRecent.trackName,
|
final recent = RecentAccessItem(
|
||||||
subtitle: mostRecent.artistName,
|
id: recentId,
|
||||||
imageUrl: mostRecent.coverUrl,
|
name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName,
|
||||||
type: RecentAccessType.track,
|
subtitle: isSingleTrack ? mostRecent.artistName : artistForKey,
|
||||||
accessedAt: mostRecent.downloadedAt,
|
imageUrl: mostRecent.coverUrl,
|
||||||
providerId: 'download',
|
type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album,
|
||||||
),
|
accessedAt: mostRecent.downloadedAt,
|
||||||
);
|
providerId: 'download',
|
||||||
} else {
|
);
|
||||||
downloadItems.add(
|
|
||||||
RecentAccessItem(
|
downloadIds.add(recentId);
|
||||||
id: '${mostRecent.albumName}|$artistForKey',
|
downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] =
|
||||||
name: mostRecent.albumName,
|
mostRecent.filePath;
|
||||||
subtitle: artistForKey,
|
if (!hiddenIds.contains(recentId)) {
|
||||||
imageUrl: mostRecent.coverUrl,
|
visibleDownloads.add(recent);
|
||||||
type: RecentAccessType.album,
|
|
||||||
accessedAt: mostRecent.downloadedAt,
|
|
||||||
providerId: 'download',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
||||||
|
if (visibleDownloads.length > 10) {
|
||||||
final visibleDownloads = <RecentAccessItem>[];
|
visibleDownloads.removeRange(10, visibleDownloads.length);
|
||||||
for (final item in downloadItems) {
|
|
||||||
if (!hiddenIds.contains(item.id)) {
|
|
||||||
visibleDownloads.add(item);
|
|
||||||
if (visibleDownloads.length >= 10) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final allItems = <RecentAccessItem>[...items, ...visibleDownloads];
|
final allItems = <RecentAccessItem>[...items, ...visibleDownloads];
|
||||||
@@ -1191,7 +1243,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
|
|
||||||
final view = _RecentAccessView(
|
final view = _RecentAccessView(
|
||||||
uniqueItems: uniqueItems,
|
uniqueItems: uniqueItems,
|
||||||
downloadItems: downloadItems,
|
downloadIds: downloadIds,
|
||||||
|
downloadFilePathByRecentKey: downloadFilePathByRecentKey,
|
||||||
hasHiddenDownloads: hiddenIds.isNotEmpty,
|
hasHiddenDownloads: hiddenIds.isNotEmpty,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1604,7 +1657,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
|
|
||||||
Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) {
|
Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) {
|
||||||
final uniqueItems = view.uniqueItems;
|
final uniqueItems = view.uniqueItems;
|
||||||
final downloadItems = view.downloadItems;
|
final downloadIds = view.downloadIds;
|
||||||
final hasHiddenDownloads = view.hasHiddenDownloads;
|
final hasHiddenDownloads = view.hasHiddenDownloads;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -1624,10 +1677,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
if (uniqueItems.isNotEmpty)
|
if (uniqueItems.isNotEmpty)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
for (final item in downloadItems) {
|
for (final id in downloadIds) {
|
||||||
ref
|
ref
|
||||||
.read(recentAccessProvider.notifier)
|
.read(recentAccessProvider.notifier)
|
||||||
.hideDownloadFromRecents(item.id);
|
.hideDownloadFromRecents(id);
|
||||||
}
|
}
|
||||||
ref.read(recentAccessProvider.notifier).clearHistory();
|
ref.read(recentAccessProvider.notifier).clearHistory();
|
||||||
},
|
},
|
||||||
@@ -1680,7 +1733,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
...uniqueItems.map(
|
...uniqueItems.map(
|
||||||
(item) => _buildRecentAccessItem(item, colorScheme),
|
(item) => _buildRecentAccessItem(
|
||||||
|
item,
|
||||||
|
colorScheme,
|
||||||
|
view.downloadFilePathByRecentKey,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1690,10 +1747,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
Widget _buildRecentAccessItem(
|
Widget _buildRecentAccessItem(
|
||||||
RecentAccessItem item,
|
RecentAccessItem item,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
|
Map<String, String> downloadFilePathByRecentKey,
|
||||||
) {
|
) {
|
||||||
IconData typeIcon;
|
IconData typeIcon;
|
||||||
String typeLabel;
|
String typeLabel;
|
||||||
final isDownloaded = item.providerId == 'download';
|
final isDownloaded = item.providerId == 'download';
|
||||||
|
final embeddedCoverPath = isDownloaded
|
||||||
|
? DownloadedEmbeddedCoverResolver.resolve(
|
||||||
|
downloadFilePathByRecentKey['${item.type.name}:${item.id}'],
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case RecentAccessType.artist:
|
case RecentAccessType.artist:
|
||||||
@@ -1723,7 +1787,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
item.type == RecentAccessType.artist ? 28 : 4,
|
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(
|
? CachedNetworkImage(
|
||||||
imageUrl: item.imageUrl!,
|
imageUrl: item.imageUrl!,
|
||||||
width: 56,
|
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);
|
_precacheCover(item.coverUrl);
|
||||||
Navigator.push(
|
final beforeModTime =
|
||||||
context,
|
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
|
||||||
|
item.filePath,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
final result = await navigator.push(
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
@@ -1909,6 +1996,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||||
|
item.filePath,
|
||||||
|
beforeModTime: beforeModTime,
|
||||||
|
force: result == true,
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _precacheCover(String? url) {
|
void _precacheCover(String? url) {
|
||||||
@@ -1916,8 +2009,19 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||||
precacheImage(
|
precacheImage(
|
||||||
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
|
ResizeImage(
|
||||||
|
CachedNetworkImageProvider(
|
||||||
|
url,
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
),
|
||||||
|
width: targetSize,
|
||||||
|
height: targetSize,
|
||||||
|
),
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
|
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
|
||||||
late List<int> _sortedDiscNumbersCache;
|
late List<int> _sortedDiscNumbersCache;
|
||||||
late bool _hasMultipleDiscsCache;
|
late bool _hasMultipleDiscsCache;
|
||||||
|
String? _commonQualityCache;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -87,6 +88,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
|
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
|
||||||
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
|
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
|
||||||
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
|
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
|
||||||
|
_commonQualityCache = _computeCommonQuality(_sortedTracksCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
|
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
|
||||||
@@ -160,15 +162,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final libraryNotifier = ref.read(localLibraryProvider.notifier);
|
final libraryNotifier = ref.read(localLibraryProvider.notifier);
|
||||||
final idsToDelete = _selectedIds.toList();
|
final idsToDelete = _selectedIds.toList();
|
||||||
|
final tracksById = {for (final track in currentTracks) track.id: track};
|
||||||
|
|
||||||
int deletedCount = 0;
|
int deletedCount = 0;
|
||||||
for (final id in idsToDelete) {
|
for (final id in idsToDelete) {
|
||||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
final item = tracksById[id];
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
try {
|
try {
|
||||||
await deleteFile(item.filePath);
|
await deleteFile(item.filePath);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
libraryNotifier.removeItem(id);
|
await libraryNotifier.removeItem(id);
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,6 +428,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
List<LocalLibraryItem> tracks,
|
List<LocalLibraryItem> tracks,
|
||||||
) {
|
) {
|
||||||
|
final commonQuality = _commonQualityCache;
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -519,22 +524,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// Quality badge if all tracks have the same quality
|
// Quality badge if all tracks have the same quality
|
||||||
if (_getCommonQuality(tracks) != null)
|
if (commonQuality != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12,
|
horizontal: 12,
|
||||||
vertical: 6,
|
vertical: 6,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getCommonQuality(tracks)!.contains('24')
|
color: commonQuality.contains('24')
|
||||||
? colorScheme.primaryContainer
|
? colorScheme.primaryContainer
|
||||||
: colorScheme.surfaceContainerHighest,
|
: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_getCommonQuality(tracks)!,
|
commonQuality,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _getCommonQuality(tracks)!.contains('24')
|
color: commonQuality.contains('24')
|
||||||
? colorScheme.onPrimaryContainer
|
? colorScheme.onPrimaryContainer
|
||||||
: colorScheme.onSurfaceVariant,
|
: colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w600,
|
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;
|
if (tracks.isEmpty) return null;
|
||||||
final first = tracks.first;
|
final first = tracks.first;
|
||||||
if (first.bitDepth == null || first.sampleRate == null) return null;
|
if (first.bitDepth == null || first.sampleRate == null) return null;
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
(constraints.maxHeight - kToolbarHeight) /
|
(constraints.maxHeight - kToolbarHeight) /
|
||||||
(expandedHeight - kToolbarHeight);
|
(expandedHeight - kToolbarHeight);
|
||||||
final showContent = collapseRatio > 0.3;
|
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(
|
return FlexibleSpaceBar(
|
||||||
collapseMode: CollapseMode.none,
|
collapseMode: CollapseMode.none,
|
||||||
@@ -192,6 +199,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: widget.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: backgroundMemCacheWidth,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
placeholder: (_, _) =>
|
placeholder: (_, _) =>
|
||||||
Container(color: colorScheme.surface),
|
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/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
import 'package:spotiflac_android/services/library_database.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/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
||||||
@@ -105,6 +106,7 @@ class _GroupedAlbum {
|
|||||||
final String albumName;
|
final String albumName;
|
||||||
final String artistName;
|
final String artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
|
final String sampleFilePath;
|
||||||
final List<DownloadHistoryItem> tracks;
|
final List<DownloadHistoryItem> tracks;
|
||||||
final DateTime latestDownload;
|
final DateTime latestDownload;
|
||||||
final String searchKey;
|
final String searchKey;
|
||||||
@@ -113,6 +115,7 @@ class _GroupedAlbum {
|
|||||||
required this.albumName,
|
required this.albumName,
|
||||||
required this.artistName,
|
required this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
required this.sampleFilePath,
|
||||||
required this.tracks,
|
required this.tracks,
|
||||||
required this.latestDownload,
|
required this.latestDownload,
|
||||||
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
|
}) : 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) {
|
Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
|
||||||
final entries = (payload['entries'] as List).cast<List>();
|
final entries = (payload['entries'] as List).cast<List>();
|
||||||
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
|
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
|
||||||
final query = (payload['query'] as String?) ?? '';
|
final query = (payload['query'] as String?) ?? '';
|
||||||
|
final hasQuery = query.isNotEmpty;
|
||||||
|
|
||||||
final allIds = <String>[];
|
final allIds = <String>[];
|
||||||
final albumIds = <String>[];
|
final albumIds = <String>[];
|
||||||
@@ -219,10 +237,11 @@ Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
|
|||||||
for (final entry in entries) {
|
for (final entry in entries) {
|
||||||
final id = entry[0] as String;
|
final id = entry[0] as String;
|
||||||
final albumKey = entry[1] as String;
|
final albumKey = entry[1] as String;
|
||||||
final searchKey = entry[2] as String;
|
if (hasQuery) {
|
||||||
|
final searchKey = entry[2] as String;
|
||||||
if (query.isNotEmpty && !searchKey.contains(query)) {
|
if (!searchKey.contains(query)) {
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allIds.add(id);
|
allIds.add(id);
|
||||||
@@ -259,6 +278,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final ValueNotifier<bool> _alwaysMissingFileNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _alwaysMissingFileNotifier = ValueNotifier(false);
|
||||||
final Set<String> _pendingChecks = {};
|
final Set<String> _pendingChecks = {};
|
||||||
static const int _maxCacheSize = 500;
|
static const int _maxCacheSize = 500;
|
||||||
|
static const int _maxSearchIndexCacheSize = 4000;
|
||||||
|
bool _embeddedCoverRefreshScheduled = false;
|
||||||
|
|
||||||
bool _isSelectionMode = false;
|
bool _isSelectionMode = false;
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
@@ -290,8 +311,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_HistoryStats? _historyStatsCache;
|
_HistoryStats? _historyStatsCache;
|
||||||
final Map<String, String> _searchIndexCache = {};
|
final Map<String, String> _searchIndexCache = {};
|
||||||
final Map<String, String> _localSearchIndexCache = {};
|
final Map<String, String> _localSearchIndexCache = {};
|
||||||
Map<String, DownloadHistoryItem> _historyItemsById = {};
|
|
||||||
List<List<String>> _historyFilterEntries = const [];
|
|
||||||
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
|
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
|
||||||
List<DownloadHistoryItem>? _filterItemsCache;
|
List<DownloadHistoryItem>? _filterItemsCache;
|
||||||
String _filterQueryCache = '';
|
String _filterQueryCache = '';
|
||||||
@@ -379,32 +398,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_historyItemsCache = items;
|
_historyItemsCache = items;
|
||||||
_localLibraryItemsCache = localItems;
|
_localLibraryItemsCache = localItems;
|
||||||
_historyStatsCache = _buildHistoryStats(items, localItems);
|
_historyStatsCache = _buildHistoryStats(items, localItems);
|
||||||
_searchIndexCache
|
if (historyChanged) {
|
||||||
..clear()
|
_searchIndexCache.clear();
|
||||||
..addEntries(
|
}
|
||||||
items.map((item) => MapEntry(item.id, _buildSearchKey(item))),
|
|
||||||
);
|
|
||||||
if (localChanged) {
|
if (localChanged) {
|
||||||
_localSearchIndexCache
|
_localSearchIndexCache.clear();
|
||||||
..clear()
|
|
||||||
..addEntries(
|
|
||||||
localItems.map(
|
|
||||||
(item) => MapEntry(item.id, _buildLocalSearchKey(item)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_localFilterItemsCache = null;
|
_localFilterItemsCache = null;
|
||||||
_localFilterQueryCache = '';
|
_localFilterQueryCache = '';
|
||||||
_filteredLocalItemsCache = const [];
|
_filteredLocalItemsCache = const [];
|
||||||
}
|
}
|
||||||
_unifiedItemsCache.clear();
|
_unifiedItemsCache.clear();
|
||||||
_historyItemsById = {for (final item in items) item.id: item};
|
|
||||||
_historyFilterEntries = List<List<String>>.generate(items.length, (index) {
|
if (historyChanged) {
|
||||||
final item = items[index];
|
final validPaths = items
|
||||||
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
|
.map((item) => _cleanFilePath(item.filePath))
|
||||||
final albumKey =
|
.where((path) => path.isNotEmpty)
|
||||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
.toSet();
|
||||||
return [item.id, albumKey, searchKey];
|
DownloadedEmbeddedCoverResolver.invalidatePathsNotIn(validPaths);
|
||||||
}, growable: false);
|
}
|
||||||
_requestFilterRefresh();
|
_requestFilterRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +429,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
.toLowerCase();
|
.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> _filterLocalItems(
|
||||||
List<LocalLibraryItem> items,
|
List<LocalLibraryItem> items,
|
||||||
String query,
|
String query,
|
||||||
@@ -430,11 +465,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
final filtered = items
|
final filtered = items
|
||||||
.where((item) {
|
.where((item) {
|
||||||
final searchKey =
|
final searchKey = _localSearchKeyForItem(item);
|
||||||
_localSearchIndexCache[item.id] ?? _buildLocalSearchKey(item);
|
|
||||||
if (!_localSearchIndexCache.containsKey(item.id)) {
|
|
||||||
_localSearchIndexCache[item.id] = searchKey;
|
|
||||||
}
|
|
||||||
return searchKey.contains(query);
|
return searchKey.contains(query);
|
||||||
})
|
})
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
@@ -507,15 +538,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final requestId = ++_filterRequestId;
|
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>{
|
final payload = <String, Object>{
|
||||||
'entries': _historyFilterEntries,
|
'entries': entries,
|
||||||
'albumCounts': albumCounts,
|
'albumCounts': albumCounts,
|
||||||
'query': query,
|
'query': query,
|
||||||
};
|
};
|
||||||
|
|
||||||
compute(_filterHistoryInIsolate, payload).then((result) {
|
compute(_filterHistoryInIsolate, payload).then((result) {
|
||||||
if (!mounted || requestId != _filterRequestId) return;
|
if (!mounted || requestId != _filterRequestId) return;
|
||||||
final itemsById = _historyItemsById;
|
final itemsById = {for (final item in items) item.id: item};
|
||||||
final filtered = <String, List<DownloadHistoryItem>>{};
|
final filtered = <String, List<DownloadHistoryItem>>{};
|
||||||
for (final entry in result.entries) {
|
for (final entry in result.entries) {
|
||||||
filtered[entry.key] = entry.value
|
filtered[entry.key] = entry.value
|
||||||
@@ -563,10 +605,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final query = searchQuery;
|
final query = searchQuery;
|
||||||
return items
|
return items
|
||||||
.where((item) {
|
.where((item) {
|
||||||
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
|
final searchKey = _historySearchKeyForItem(item);
|
||||||
if (!_searchIndexCache.containsKey(item.id)) {
|
|
||||||
_searchIndexCache[item.id] = searchKey;
|
|
||||||
}
|
|
||||||
return searchKey.contains(query);
|
return searchKey.contains(query);
|
||||||
})
|
})
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
@@ -646,13 +685,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getQualityBadgeText(String quality) {
|
String _getQualityBadgeText(String quality) {
|
||||||
if (quality.contains('bit')) {
|
final q = quality.trim().toLowerCase();
|
||||||
|
if (q.contains('bit')) {
|
||||||
return quality.split('/').first;
|
return quality.split('/').first;
|
||||||
}
|
}
|
||||||
final bitrateMatch = RegExp(r'(\d+)kbps').firstMatch(quality);
|
|
||||||
if (bitrateMatch != null) {
|
// Supports "MP3 320k", "Opus 256kbps", etc.
|
||||||
return '${bitrateMatch.group(1)}k';
|
final bitrateTextMatch = RegExp(
|
||||||
|
r'(\d+)\s*k(?:bps)?',
|
||||||
|
caseSensitive: false,
|
||||||
|
).firstMatch(quality);
|
||||||
|
if (bitrateTextMatch != null) {
|
||||||
|
return '${bitrateTextMatch.group(1)}k';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Supports legacy quality IDs like "opus_256" / "mp3_320".
|
||||||
|
final bitrateIdMatch = RegExp(r'_(\d+)$').firstMatch(q);
|
||||||
|
if (bitrateIdMatch != null) {
|
||||||
|
return '${bitrateIdMatch.group(1)}k';
|
||||||
|
}
|
||||||
|
|
||||||
return quality.split(' ').first;
|
return quality.split(' ').first;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,10 +734,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||||
final localLibraryDb = LibraryDatabase.instance;
|
final localLibraryDb = LibraryDatabase.instance;
|
||||||
|
final itemsById = {for (final item in allItems) item.id: item};
|
||||||
|
|
||||||
int deletedCount = 0;
|
int deletedCount = 0;
|
||||||
for (final id in _selectedIds) {
|
for (final id in _selectedIds) {
|
||||||
final item = allItems.where((e) => e.id == id).firstOrNull;
|
final item = itemsById[id];
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
try {
|
try {
|
||||||
final cleanPath = _cleanFilePath(item.filePath);
|
final cleanPath = _cleanFilePath(item.filePath);
|
||||||
@@ -725,11 +778,42 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
/// Strip EXISTS: prefix from file path (legacy history items)
|
/// Strip EXISTS: prefix from file path (legacy history items)
|
||||||
String _cleanFilePath(String? filePath) {
|
String _cleanFilePath(String? filePath) {
|
||||||
if (filePath == null) return '';
|
return DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
|
||||||
if (filePath.startsWith('EXISTS:')) {
|
}
|
||||||
return filePath.substring(7);
|
|
||||||
}
|
Future<int?> _readFileModTimeMillis(String? filePath) async {
|
||||||
return filePath;
|
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) {
|
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> _applyAdvancedFilters(
|
||||||
List<UnifiedLibraryItem> items,
|
List<UnifiedLibraryItem> items,
|
||||||
) {
|
) {
|
||||||
@@ -841,7 +940,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_filterFormat != null) {
|
if (_filterFormat != null) {
|
||||||
final ext = item.filePath.split('.').last.toLowerCase();
|
final ext = _fileExtLower(item.filePath);
|
||||||
if (ext != _filterFormat) return false;
|
if (ext != _filterFormat) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,7 +996,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
/// Check if a file path passes the current format filter
|
/// Check if a file path passes the current format filter
|
||||||
bool _passesFormatFilter(String filePath) {
|
bool _passesFormatFilter(String filePath) {
|
||||||
if (_filterFormat == null) return true;
|
if (_filterFormat == null) return true;
|
||||||
return filePath.split('.').last.toLowerCase() == _filterFormat;
|
return _fileExtLower(filePath) == _filterFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filter grouped download albums by search query + advanced filters
|
/// 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
|
// Filter tracks within the album by advanced filters
|
||||||
if (_filterQuality != null || _filterFormat != null) {
|
if (_filterQuality != null || _filterFormat != null) {
|
||||||
final filteredTracks = album.tracks
|
var hasMatchingTrack = false;
|
||||||
.where((track) {
|
for (final track in album.tracks) {
|
||||||
if (!_passesQualityFilter(track.quality)) return false;
|
if (!_passesQualityFilter(track.quality)) continue;
|
||||||
if (!_passesFormatFilter(track.filePath)) return false;
|
if (!_passesFormatFilter(track.filePath)) continue;
|
||||||
return true;
|
hasMatchingTrack = true;
|
||||||
})
|
break;
|
||||||
.toList(growable: false);
|
}
|
||||||
|
|
||||||
if (filteredTracks.isEmpty) continue;
|
if (!hasMatchingTrack) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
result.add(album);
|
result.add(album);
|
||||||
@@ -979,20 +1078,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
// Filter tracks within the album by advanced filters
|
// Filter tracks within the album by advanced filters
|
||||||
if (_filterQuality != null || _filterFormat != null) {
|
if (_filterQuality != null || _filterFormat != null) {
|
||||||
final filteredTracks = album.tracks
|
var hasMatchingTrack = false;
|
||||||
.where((track) {
|
for (final track in album.tracks) {
|
||||||
String? quality;
|
if (!_passesQualityFilter(_localQualityLabel(track))) continue;
|
||||||
if (track.bitDepth != null && track.sampleRate != null) {
|
if (!_passesFormatFilter(track.filePath)) continue;
|
||||||
quality =
|
hasMatchingTrack = true;
|
||||||
'${track.bitDepth}bit/${(track.sampleRate! / 1000).toStringAsFixed(1)}kHz';
|
break;
|
||||||
}
|
}
|
||||||
if (!_passesQualityFilter(quality)) return false;
|
|
||||||
if (!_passesFormatFilter(track.filePath)) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.toList(growable: false);
|
|
||||||
|
|
||||||
if (filteredTracks.isEmpty) continue;
|
if (!hasMatchingTrack) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
result.add(album);
|
result.add(album);
|
||||||
@@ -1022,7 +1116,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
|
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
|
||||||
final formats = <String>{};
|
final formats = <String>{};
|
||||||
for (final item in items) {
|
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)) {
|
if (['flac', 'mp3', 'm4a', 'opus', 'ogg', 'wav', 'aiff'].contains(ext)) {
|
||||||
formats.add(ext);
|
formats.add(ext);
|
||||||
}
|
}
|
||||||
@@ -1274,13 +1368,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||||
precacheImage(
|
precacheImage(
|
||||||
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
|
ResizeImage(
|
||||||
|
CachedNetworkImageProvider(
|
||||||
|
url,
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
),
|
||||||
|
width: targetSize,
|
||||||
|
height: targetSize,
|
||||||
|
),
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToMetadataScreen(DownloadItem item) {
|
Future<void> _navigateToMetadataScreen(DownloadItem item) async {
|
||||||
final historyItem = ref
|
final historyItem = ref
|
||||||
.read(downloadHistoryProvider)
|
.read(downloadHistoryProvider)
|
||||||
.items
|
.items
|
||||||
@@ -1298,10 +1403,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
_precacheCover(historyItem.coverUrl);
|
_precacheCover(historyItem.coverUrl);
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
Navigator.push(
|
final beforeModTime = await _readFileModTimeMillis(historyItem.filePath);
|
||||||
context,
|
if (!mounted) return;
|
||||||
|
final result = await navigator.push(
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
@@ -1310,14 +1417,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
FadeTransition(opacity: animation, child: 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);
|
_precacheCover(item.coverUrl);
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
Navigator.push(
|
final beforeModTime = await _readFileModTimeMillis(item.filePath);
|
||||||
context,
|
if (!mounted) return;
|
||||||
|
final result = await navigator.push(
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
@@ -1326,7 +1450,20 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
FadeTransition(opacity: animation, child: 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) {
|
void _navigateToLocalMetadataScreen(LocalLibraryItem item) {
|
||||||
@@ -1355,10 +1492,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
if (searchQuery.isNotEmpty) {
|
if (searchQuery.isNotEmpty) {
|
||||||
final query = searchQuery;
|
final query = searchQuery;
|
||||||
filteredItems = items.where((item) {
|
filteredItems = items.where((item) {
|
||||||
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
|
final searchKey = _historySearchKeyForItem(item);
|
||||||
if (!_searchIndexCache.containsKey(item.id)) {
|
|
||||||
_searchIndexCache[item.id] = searchKey;
|
|
||||||
}
|
|
||||||
return searchKey.contains(query);
|
return searchKey.contains(query);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
@@ -1421,6 +1555,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
albumName: tracks.first.albumName,
|
albumName: tracks.first.albumName,
|
||||||
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
|
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
|
||||||
coverUrl: tracks.first.coverUrl,
|
coverUrl: tracks.first.coverUrl,
|
||||||
|
sampleFilePath: tracks.first.filePath,
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
latestDownload: tracks
|
latestDownload: tracks
|
||||||
.map((t) => t.downloadedAt)
|
.map((t) => t.downloadedAt)
|
||||||
@@ -1544,7 +1679,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_initializePageController();
|
_initializePageController();
|
||||||
|
|
||||||
final hasQueueItems = ref.watch(
|
final hasQueueItems = ref.watch(
|
||||||
downloadQueueProvider.select((s) => s.items.isNotEmpty),
|
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty),
|
||||||
);
|
);
|
||||||
final allHistoryItems = ref.watch(
|
final allHistoryItems = ref.watch(
|
||||||
downloadHistoryProvider.select((s) => s.items),
|
downloadHistoryProvider.select((s) => s.items),
|
||||||
@@ -1572,6 +1707,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_buildHistoryStats(allHistoryItems, localLibraryItems);
|
_buildHistoryStats(allHistoryItems, localLibraryItems);
|
||||||
final groupedAlbums = historyStats.groupedAlbums;
|
final groupedAlbums = historyStats.groupedAlbums;
|
||||||
final groupedLocalAlbums = historyStats.groupedLocalAlbums;
|
final groupedLocalAlbums = historyStats.groupedLocalAlbums;
|
||||||
|
final filteredGroupedAlbums = _filterGroupedAlbums(
|
||||||
|
groupedAlbums,
|
||||||
|
_searchQuery,
|
||||||
|
);
|
||||||
|
final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums(
|
||||||
|
groupedLocalAlbums,
|
||||||
|
_searchQuery,
|
||||||
|
);
|
||||||
final albumCount = historyStats.totalAlbumCount;
|
final albumCount = historyStats.totalAlbumCount;
|
||||||
final singleCount = historyStats.totalSingleTracks;
|
final singleCount = historyStats.totalSingleTracks;
|
||||||
final filterDataCache = <String, _FilterContentData>{};
|
final filterDataCache = <String, _FilterContentData>{};
|
||||||
@@ -1582,8 +1725,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
() => _computeFilterContentData(
|
() => _computeFilterContentData(
|
||||||
filterMode: filterMode,
|
filterMode: filterMode,
|
||||||
allHistoryItems: allHistoryItems,
|
allHistoryItems: allHistoryItems,
|
||||||
groupedAlbums: groupedAlbums,
|
filteredGroupedAlbums: filteredGroupedAlbums,
|
||||||
groupedLocalAlbums: groupedLocalAlbums,
|
filteredGroupedLocalAlbums: filteredGroupedLocalAlbums,
|
||||||
albumCounts: historyStats.albumCounts,
|
albumCounts: historyStats.albumCounts,
|
||||||
localAlbumCounts: historyStats.localAlbumCounts,
|
localAlbumCounts: historyStats.localAlbumCounts,
|
||||||
localLibraryItems: localLibraryItems,
|
localLibraryItems: localLibraryItems,
|
||||||
@@ -1647,7 +1790,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Search bar - always at top
|
// Search bar - always at top
|
||||||
if (allHistoryItems.isNotEmpty || hasQueueItems || localLibraryItems.isNotEmpty)
|
if (allHistoryItems.isNotEmpty ||
|
||||||
|
hasQueueItems ||
|
||||||
|
localLibraryItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
@@ -1972,8 +2117,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_FilterContentData _computeFilterContentData({
|
_FilterContentData _computeFilterContentData({
|
||||||
required String filterMode,
|
required String filterMode,
|
||||||
required List<DownloadHistoryItem> allHistoryItems,
|
required List<DownloadHistoryItem> allHistoryItems,
|
||||||
required List<_GroupedAlbum> groupedAlbums,
|
required List<_GroupedAlbum> filteredGroupedAlbums,
|
||||||
required List<_GroupedLocalAlbum> groupedLocalAlbums,
|
required List<_GroupedLocalAlbum> filteredGroupedLocalAlbums,
|
||||||
required Map<String, int> albumCounts,
|
required Map<String, int> albumCounts,
|
||||||
required Map<String, int> localAlbumCounts,
|
required Map<String, int> localAlbumCounts,
|
||||||
required List<LocalLibraryItem> localLibraryItems,
|
required List<LocalLibraryItem> localLibraryItems,
|
||||||
@@ -1988,16 +2133,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
filterMode: filterMode,
|
filterMode: filterMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
final searchQuery = _searchQuery;
|
|
||||||
final filteredGroupedAlbums = _filterGroupedAlbums(
|
|
||||||
groupedAlbums,
|
|
||||||
searchQuery,
|
|
||||||
);
|
|
||||||
final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums(
|
|
||||||
groupedLocalAlbums,
|
|
||||||
searchQuery,
|
|
||||||
);
|
|
||||||
|
|
||||||
final unifiedItems = _getUnifiedItems(
|
final unifiedItems = _getUnifiedItems(
|
||||||
filterMode: filterMode,
|
filterMode: filterMode,
|
||||||
historyItems: historyItems,
|
historyItems: historyItems,
|
||||||
@@ -2023,7 +2158,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
return Consumer(
|
return Consumer(
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
final queueCount = ref.watch(
|
final queueCount = ref.watch(
|
||||||
downloadQueueProvider.select((s) => s.items.length),
|
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.length),
|
||||||
);
|
);
|
||||||
if (queueCount == 0) {
|
if (queueCount == 0) {
|
||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
@@ -2054,20 +2189,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
Widget _buildQueueItemsSliver(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildQueueItemsSliver(BuildContext context, ColorScheme colorScheme) {
|
||||||
return Consumer(
|
return Consumer(
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
final queueItems = ref.watch(
|
final queueIdsSnapshot = ref.watch(
|
||||||
downloadQueueProvider.select((s) => s.items),
|
downloadQueueLookupProvider.select(
|
||||||
|
(lookup) => _QueueItemIdsSnapshot(lookup.itemIds),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (queueItems.isEmpty) {
|
if (queueIdsSnapshot.ids.isEmpty) {
|
||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
}
|
}
|
||||||
return SliverList(
|
return SliverList(
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
final item = queueItems[index];
|
final itemId = queueIdsSnapshot.ids[index];
|
||||||
return KeyedSubtree(
|
return _QueueItemSliverRow(
|
||||||
key: ValueKey(item.id),
|
key: ValueKey(itemId),
|
||||||
child: _buildQueueItem(context, item, colorScheme),
|
itemId: itemId,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
itemBuilder: _buildQueueItem,
|
||||||
);
|
);
|
||||||
}, childCount: queueItems.length),
|
}, childCount: queueIdsSnapshot.ids.length),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -2093,7 +2232,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
if (totalTrackCount > 0 && !hasQueueItems && filterMode == 'all')
|
if (totalTrackCount > 0 && filterMode == 'all')
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -2143,7 +2282,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
if ((filteredGroupedAlbums.isNotEmpty ||
|
if ((filteredGroupedAlbums.isNotEmpty ||
|
||||||
filteredGroupedLocalAlbums.isNotEmpty) &&
|
filteredGroupedLocalAlbums.isNotEmpty) &&
|
||||||
!hasQueueItems &&
|
|
||||||
filterMode == 'albums')
|
filterMode == 'albums')
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -2180,7 +2318,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
// Albums empty state with filter button
|
// Albums empty state with filter button
|
||||||
if (filteredGroupedAlbums.isEmpty &&
|
if (filteredGroupedAlbums.isEmpty &&
|
||||||
filteredGroupedLocalAlbums.isEmpty &&
|
filteredGroupedLocalAlbums.isEmpty &&
|
||||||
!hasQueueItems &&
|
|
||||||
filterMode == 'albums' &&
|
filterMode == 'albums' &&
|
||||||
(historyItems.isNotEmpty || localLibraryItems.isNotEmpty))
|
(historyItems.isNotEmpty || localLibraryItems.isNotEmpty))
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -2331,7 +2468,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Singles filter - show unified items (downloaded + local singles)
|
// Singles filter - show unified items (downloaded + local singles)
|
||||||
if (filterMode == 'singles' && !hasQueueItems)
|
if (filterMode == 'singles')
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -2559,6 +2696,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_GroupedAlbum album,
|
_GroupedAlbum album,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
|
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
|
||||||
|
album.sampleFilePath,
|
||||||
|
);
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _navigateToDownloadedAlbum(album),
|
onTap: () => _navigateToDownloadedAlbum(album),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -2569,7 +2709,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
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(
|
? CachedNetworkImage(
|
||||||
imageUrl: album.coverUrl!,
|
imageUrl: album.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
@@ -2946,13 +3106,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
// show bytes downloaded instead of percentage
|
// show bytes downloaded instead of percentage
|
||||||
item.progress > 0
|
item.progress > 0
|
||||||
? (item.speedMBps > 0
|
? (item.speedMBps > 0
|
||||||
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||||
: '${(item.progress * 100).toStringAsFixed(0)}%')
|
: '${(item.progress * 100).toStringAsFixed(0)}%')
|
||||||
: (item.bytesReceived > 0
|
: (item.bytesReceived > 0
|
||||||
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||||
: (item.speedMBps > 0
|
: (item.speedMBps > 0
|
||||||
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||||
: 'Starting...')),
|
: 'Starting...')),
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
@@ -3139,6 +3299,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
double size,
|
double size,
|
||||||
) {
|
) {
|
||||||
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
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)
|
// Network URL cover (downloaded items)
|
||||||
if (item.coverUrl != null) {
|
if (item.coverUrl != null) {
|
||||||
@@ -3220,6 +3400,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
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)
|
// Network URL cover (downloaded items)
|
||||||
if (item.coverUrl != null) {
|
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 {
|
class _FilterChip extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final int count;
|
final int count;
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
if (widget.query.isNotEmpty) {
|
if (widget.query.isNotEmpty) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final settings = ref.read(settingsProvider);
|
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();
|
final query = _searchController.text.trim();
|
||||||
if (query.isNotEmpty) {
|
if (query.isNotEmpty) {
|
||||||
final settings = ref.read(settingsProvider);
|
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) {
|
void _downloadTrack(Track track) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(
|
ref
|
||||||
track,
|
.read(downloadQueueProvider.notifier)
|
||||||
settings.defaultService,
|
.addToQueue(track, settings.defaultService);
|
||||||
);
|
ScaffoldMessenger.of(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
context,
|
||||||
SnackBar(content: Text('Added "${track.name}" to queue')),
|
).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -78,10 +81,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
autofocus: widget.query.isEmpty,
|
autofocus: widget.query.isEmpty,
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(icon: const Icon(Icons.search), onPressed: _search),
|
||||||
icon: const Icon(Icons.search),
|
|
||||||
onPressed: _search,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
@@ -92,7 +92,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
trackState.error!,
|
trackState.error!,
|
||||||
style: TextStyle(color: colorScheme.error),
|
style: TextStyle(color: colorScheme.error),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -115,11 +115,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.search, size: 64, color: colorScheme.onSurfaceVariant),
|
||||||
Icons.search,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Search for tracks',
|
'Search for tracks',
|
||||||
@@ -137,11 +133,13 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
leading: track.coverUrl != null
|
leading: track.coverUrl != null
|
||||||
? ClipRRect(
|
? ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
imageUrl: track.coverUrl!,
|
imageUrl: track.coverUrl!,
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 144,
|
||||||
|
memCacheHeight: 144,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -152,15 +150,18 @@ child: CachedNetworkImage(
|
|||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
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),
|
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
track.artistName,
|
track.artistName,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -384,6 +384,8 @@ class _ContributorItem extends StatelessWidget {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 120,
|
||||||
|
memCacheHeight: 120,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
|
|||||||
@@ -60,19 +60,30 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<_CacheOverview> _buildOverview() async {
|
Future<_CacheOverview> _buildOverview() async {
|
||||||
final appCacheDir = await getApplicationCacheDirectory();
|
final appCacheDirFuture = getApplicationCacheDirectory();
|
||||||
final tempDir = await getTemporaryDirectory();
|
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 appCachePath = p.normalize(appCacheDir.path);
|
||||||
final tempPath = p.normalize(tempDir.path);
|
final tempPath = p.normalize(tempDir.path);
|
||||||
final tempIsSameAsAppCache = appCachePath == tempPath;
|
final tempIsSameAsAppCache = appCachePath == tempPath;
|
||||||
|
|
||||||
final appCacheStats = await _scanDirectory(Directory(appCachePath));
|
final appCacheStatsFuture = _scanDirectory(Directory(appCachePath));
|
||||||
final tempStats = tempIsSameAsAppCache
|
final tempStatsFuture = tempIsSameAsAppCache
|
||||||
? null
|
? Future<_DirectoryStats?>.value(null)
|
||||||
: await _scanDirectory(Directory(tempPath));
|
: _scanDirectory(Directory(tempPath));
|
||||||
final coverStats = await CoverCacheManager.getStats();
|
|
||||||
|
|
||||||
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 explorePayload = prefs.getString(_exploreCacheKey);
|
||||||
final exploreTs = prefs.getInt(_exploreCacheTsKey);
|
final exploreTs = prefs.getInt(_exploreCacheTsKey);
|
||||||
var exploreBytes = 0;
|
var exploreBytes = 0;
|
||||||
@@ -84,16 +95,11 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
|||||||
}
|
}
|
||||||
final hasExploreCache = exploreBytes > 0;
|
final hasExploreCache = exploreBytes > 0;
|
||||||
|
|
||||||
int trackCacheEntries;
|
final appCacheStats = await appCacheStatsFuture;
|
||||||
try {
|
final tempStats = await tempStatsFuture;
|
||||||
trackCacheEntries = await PlatformBridge.getTrackCacheSize();
|
final coverStats = await coverStatsFuture;
|
||||||
} catch (_) {
|
final libraryCoverStats = await libraryCoverStatsFuture;
|
||||||
trackCacheEntries = 0;
|
final trackCacheEntries = await trackCacheEntriesFuture;
|
||||||
}
|
|
||||||
|
|
||||||
final appSupportDir = await getApplicationSupportDirectory();
|
|
||||||
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
|
|
||||||
final libraryCoverStats = await _scanDirectory(libraryCoverDir);
|
|
||||||
|
|
||||||
return _CacheOverview(
|
return _CacheOverview(
|
||||||
appCachePath: appCachePath,
|
appCachePath: appCachePath,
|
||||||
@@ -132,16 +138,37 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
|||||||
return _DirectoryStats(fileCount: fileCount, totalSizeBytes: totalSize);
|
return _DirectoryStats(fileCount: fileCount, totalSizeBytes: totalSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> _getTrackCacheSizeSafe() async {
|
||||||
|
try {
|
||||||
|
return await PlatformBridge.getTrackCacheSize();
|
||||||
|
} catch (_) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _clearDirectoryContents(String path) async {
|
Future<void> _clearDirectoryContents(String path) async {
|
||||||
final directory = Directory(path);
|
final directory = Directory(path);
|
||||||
if (!await directory.exists()) return;
|
if (!await directory.exists()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final entities = directory.listSync(followLinks: false);
|
final entities = <FileSystemEntity>[];
|
||||||
for (final entity in entities) {
|
await for (final entity in directory.list(followLinks: false)) {
|
||||||
try {
|
entities.add(entity);
|
||||||
await entity.delete(recursive: true);
|
}
|
||||||
} catch (_) {}
|
|
||||||
|
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 (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
@@ -583,7 +610,9 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
|||||||
subtitle: _buildSubtitle(
|
subtitle: _buildSubtitle(
|
||||||
context.l10n.cacheTrackLookupDesc,
|
context.l10n.cacheTrackLookupDesc,
|
||||||
overview.trackCacheEntries > 0
|
overview.trackCacheEntries > 0
|
||||||
? context.l10n.cacheEntries(overview.trackCacheEntries)
|
? context.l10n.cacheEntries(
|
||||||
|
overview.trackCacheEntries,
|
||||||
|
)
|
||||||
: context.l10n.cacheNoData,
|
: context.l10n.cacheNoData,
|
||||||
),
|
),
|
||||||
trailing: _buildClearTrailing(
|
trailing: _buildClearTrailing(
|
||||||
@@ -611,7 +640,8 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
|||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.cleaning_services_outlined,
|
icon: Icons.cleaning_services_outlined,
|
||||||
title: context.l10n.cacheCleanupUnused,
|
title: context.l10n.cacheCleanupUnused,
|
||||||
subtitle: '${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
|
subtitle:
|
||||||
|
'${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
|
||||||
trailing: _buildClearTrailing(
|
trailing: _buildClearTrailing(
|
||||||
'cleanup_unused',
|
'cleanup_unused',
|
||||||
_cleanupUnusedData,
|
_cleanupUnusedData,
|
||||||
|
|||||||
@@ -202,9 +202,11 @@ class _RecentDonorsCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_DonorTile(name: 'J', colorScheme: colorScheme),
|
_DonorTile(name: 'J', colorScheme: colorScheme),
|
||||||
_DonorTile(name: 'Julian', colorScheme: colorScheme),
|
_DonorTile(name: 'Julian', colorScheme: colorScheme),
|
||||||
|
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
|
||||||
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
||||||
|
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
|
||||||
_DonorTile(
|
_DonorTile(
|
||||||
name: '283Fabio',
|
name: 'Elias el Autentico',
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -255,21 +257,6 @@ class _DonateLinksCard extends StatelessWidget {
|
|||||||
endIndent: 16,
|
endIndent: 16,
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
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(
|
_DonateCardItem(
|
||||||
title: 'GitHub Sponsors',
|
title: 'GitHub Sponsors',
|
||||||
subtitle: 'github.com/sponsors/zarzet',
|
subtitle: 'github.com/sponsors/zarzet',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
static const _builtInServices = ['tidal', 'qobuz'];
|
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||||
int _androidSdkVersion = 0;
|
int _androidSdkVersion = 0;
|
||||||
bool _hasAllFilesAccess = false;
|
bool _hasAllFilesAccess = false;
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Select Tidal or Qobuz above to configure quality',
|
'Select Tidal, Qobuz, or Amazon above to configure quality',
|
||||||
style: Theme.of(context).textTheme.bodySmall
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -365,6 +365,18 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
.setUseAlbumArtistForFolders(value),
|
.setUseAlbumArtistForFolders(value),
|
||||||
showDivider: false,
|
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 = ![
|
final isExtensionService = ![
|
||||||
'tidal',
|
'tidal',
|
||||||
'qobuz',
|
'qobuz',
|
||||||
|
'amazon',
|
||||||
].contains(currentService);
|
].contains(currentService);
|
||||||
final isCurrentExtensionEnabled = isExtensionService
|
final isCurrentExtensionEnabled = isExtensionService
|
||||||
? extensionProviders.any((e) => e.id == currentService)
|
? extensionProviders.any((e) => e.id == currentService)
|
||||||
@@ -1380,6 +1393,13 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
isSelected: effectiveService == 'qobuz',
|
isSelected: effectiveService == 'qobuz',
|
||||||
onTap: () => onChanged('qobuz'),
|
onTap: () => onChanged('qobuz'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(
|
||||||
|
icon: Icons.shopping_bag_outlined,
|
||||||
|
label: 'Amazon',
|
||||||
|
isSelected: effectiveService == 'amazon',
|
||||||
|
onTap: () => onChanged('amazon'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (extensionProviders.isNotEmpty) ...[
|
if (extensionProviders.isNotEmpty) ...[
|
||||||
|
|||||||
+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';
|
return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<String> _buildDecryptionKeyCandidates(String rawKey) {
|
||||||
|
final candidates = <String>[];
|
||||||
|
|
||||||
|
void addCandidate(String key) {
|
||||||
|
final normalized = key.trim();
|
||||||
|
if (normalized.isEmpty) return;
|
||||||
|
if (!candidates.contains(normalized)) {
|
||||||
|
candidates.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final trimmed = rawKey.trim();
|
||||||
|
if (trimmed.isEmpty) return candidates;
|
||||||
|
|
||||||
|
addCandidate(trimmed);
|
||||||
|
|
||||||
|
final noPrefix = trimmed.startsWith(RegExp(r'0x', caseSensitive: false))
|
||||||
|
? trimmed.substring(2)
|
||||||
|
: trimmed;
|
||||||
|
addCandidate(noPrefix);
|
||||||
|
|
||||||
|
final compactHex = noPrefix.replaceAll(RegExp(r'[^0-9a-fA-F]'), '');
|
||||||
|
if (compactHex.isNotEmpty && compactHex.length.isEven) {
|
||||||
|
addCandidate(compactHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final b64 = noPrefix.replaceAll(RegExp(r'\s+'), '');
|
||||||
|
final decoded = base64Decode(b64);
|
||||||
|
if (decoded.isNotEmpty) {
|
||||||
|
final hex = decoded
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
if (hex.isNotEmpty) {
|
||||||
|
addCandidate(hex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<FFmpegResult> _execute(String command) async {
|
static Future<FFmpegResult> _execute(String command) async {
|
||||||
try {
|
try {
|
||||||
final session = await FFmpegKit.execute(command);
|
final session = await FFmpegKit.execute(command);
|
||||||
@@ -77,7 +119,7 @@ class FFmpegService {
|
|||||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||||
|
|
||||||
final command =
|
final command =
|
||||||
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
'-v error -xerror -i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||||
|
|
||||||
final result = await _execute(command);
|
final result = await _execute(command);
|
||||||
|
|
||||||
@@ -133,6 +175,111 @@ class FFmpegService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<String?> decryptAudioFile({
|
||||||
|
required String inputPath,
|
||||||
|
required String decryptionKey,
|
||||||
|
bool deleteOriginal = true,
|
||||||
|
}) async {
|
||||||
|
final trimmedKey = decryptionKey.trim();
|
||||||
|
if (trimmedKey.isEmpty) return inputPath;
|
||||||
|
|
||||||
|
// Amazon encrypted streams are commonly MP4 container with FLAC audio.
|
||||||
|
// Prefer FLAC output to avoid MP4 muxing errors during decrypt copy.
|
||||||
|
final preferredExt = inputPath.toLowerCase().endsWith('.m4a')
|
||||||
|
? '.flac'
|
||||||
|
: inputPath.toLowerCase().endsWith('.flac')
|
||||||
|
? '.flac'
|
||||||
|
: inputPath.toLowerCase().endsWith('.mp3')
|
||||||
|
? '.mp3'
|
||||||
|
: inputPath.toLowerCase().endsWith('.opus')
|
||||||
|
? '.opus'
|
||||||
|
: '.flac';
|
||||||
|
var tempOutput = _buildOutputPath(inputPath, preferredExt);
|
||||||
|
|
||||||
|
String buildDecryptCommand(
|
||||||
|
String outputPath, {
|
||||||
|
required bool mapAudioOnly,
|
||||||
|
required String key,
|
||||||
|
}) {
|
||||||
|
final audioMap = mapAudioOnly ? '-map 0:a ' : '';
|
||||||
|
return '-v error -decryption_key "$key" -i "$inputPath" $audioMap-c copy "$outputPath" -y';
|
||||||
|
}
|
||||||
|
|
||||||
|
final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey);
|
||||||
|
if (keyCandidates.isEmpty) {
|
||||||
|
_log.e('No usable decryption key candidates');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
FFmpegResult? lastResult;
|
||||||
|
var decryptSucceeded = false;
|
||||||
|
|
||||||
|
for (final keyCandidate in keyCandidates) {
|
||||||
|
_log.d(
|
||||||
|
'Executing FFmpeg decrypt command (key length: ${keyCandidate.length})',
|
||||||
|
);
|
||||||
|
var result = await _execute(
|
||||||
|
buildDecryptCommand(
|
||||||
|
tempOutput,
|
||||||
|
mapAudioOnly: preferredExt == '.flac',
|
||||||
|
key: keyCandidate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback for uncommon streams that cannot be remuxed into FLAC.
|
||||||
|
if (!result.success && preferredExt == '.flac') {
|
||||||
|
final fallbackOutput = _buildOutputPath(inputPath, '.m4a');
|
||||||
|
final fallbackResult = await _execute(
|
||||||
|
buildDecryptCommand(
|
||||||
|
fallbackOutput,
|
||||||
|
mapAudioOnly: false,
|
||||||
|
key: keyCandidate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (fallbackResult.success) {
|
||||||
|
tempOutput = fallbackOutput;
|
||||||
|
result = fallbackResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
decryptSucceeded = true;
|
||||||
|
lastResult = result;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
lastResult = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decryptSucceeded) {
|
||||||
|
_log.e('FFmpeg decrypt failed: ${lastResult?.output ?? 'unknown error'}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
final inputFile = File(inputPath);
|
||||||
|
if (!await tempFile.exists()) {
|
||||||
|
_log.e('Decrypted output file not found: $tempOutput');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteOriginal && await inputFile.exists()) {
|
||||||
|
await inputFile.delete();
|
||||||
|
}
|
||||||
|
return tempOutput;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to finalize decrypted file: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<String?> convertFlacToMp3(
|
static Future<String?> convertFlacToMp3(
|
||||||
String inputPath, {
|
String inputPath, {
|
||||||
String bitrate = '320k',
|
String bitrate = '320k',
|
||||||
@@ -616,6 +763,97 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unified audio format conversion with full metadata + cover preservation.
|
||||||
|
/// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format).
|
||||||
|
/// Returns the new file path on success, null on failure.
|
||||||
|
static Future<String?> convertAudioFormat({
|
||||||
|
required String inputPath,
|
||||||
|
required String targetFormat,
|
||||||
|
required String bitrate,
|
||||||
|
required Map<String, String> metadata,
|
||||||
|
String? coverPath,
|
||||||
|
bool deleteOriginal = true,
|
||||||
|
}) async {
|
||||||
|
final format = targetFormat.toLowerCase();
|
||||||
|
if (format != 'mp3' && format != 'opus') {
|
||||||
|
_log.e('Unsupported target format: $targetFormat');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final extension = format == 'opus' ? '.opus' : '.mp3';
|
||||||
|
final outputPath = _buildOutputPath(inputPath, extension);
|
||||||
|
|
||||||
|
// Step 1: Convert audio
|
||||||
|
String command;
|
||||||
|
if (format == 'opus') {
|
||||||
|
command =
|
||||||
|
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
|
||||||
|
} else {
|
||||||
|
command =
|
||||||
|
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 "$outputPath" -y';
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i(
|
||||||
|
'Converting ${inputPath.split(Platform.pathSeparator).last} to $format @ $bitrate',
|
||||||
|
);
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
_log.e('Audio conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Embed metadata + cover into the converted file.
|
||||||
|
// Treat embed failure as conversion failure when metadata/cover was requested.
|
||||||
|
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
|
||||||
|
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
|
||||||
|
if (hasMetadata || hasCover) {
|
||||||
|
String? embedResult;
|
||||||
|
if (format == 'mp3') {
|
||||||
|
embedResult = await embedMetadataToMp3(
|
||||||
|
mp3Path: outputPath,
|
||||||
|
coverPath: coverPath,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
embedResult = await embedMetadataToOpus(
|
||||||
|
opusPath: outputPath,
|
||||||
|
coverPath: coverPath,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedResult == null) {
|
||||||
|
_log.e(
|
||||||
|
'Metadata/Cover preservation failed, rolling back converted file',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final out = File(outputPath);
|
||||||
|
if (await out.exists()) {
|
||||||
|
await out.delete();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to cleanup failed converted file: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Delete original if requested
|
||||||
|
if (deleteOriginal) {
|
||||||
|
try {
|
||||||
|
await File(inputPath).delete();
|
||||||
|
_log.i(
|
||||||
|
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to delete original: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
static Map<String, String> _convertToId3Tags(
|
static Map<String, String> _convertToId3Tags(
|
||||||
Map<String, String> vorbisMetadata,
|
Map<String, String> vorbisMetadata,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -17,21 +17,21 @@ String? _currentContainerPath;
|
|||||||
class HistoryDatabase {
|
class HistoryDatabase {
|
||||||
static final HistoryDatabase instance = HistoryDatabase._init();
|
static final HistoryDatabase instance = HistoryDatabase._init();
|
||||||
static Database? _database;
|
static Database? _database;
|
||||||
|
|
||||||
HistoryDatabase._init();
|
HistoryDatabase._init();
|
||||||
|
|
||||||
Future<Database> get database async {
|
Future<Database> get database async {
|
||||||
if (_database != null) return _database!;
|
if (_database != null) return _database!;
|
||||||
_database = await _initDB('history.db');
|
_database = await _initDB('history.db');
|
||||||
return _database!;
|
return _database!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Database> _initDB(String fileName) async {
|
Future<Database> _initDB(String fileName) async {
|
||||||
final dbPath = await getApplicationDocumentsDirectory();
|
final dbPath = await getApplicationDocumentsDirectory();
|
||||||
final path = join(dbPath.path, fileName);
|
final path = join(dbPath.path, fileName);
|
||||||
|
|
||||||
_log.i('Initializing database at: $path');
|
_log.i('Initializing database at: $path');
|
||||||
|
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 3,
|
version: 3,
|
||||||
@@ -39,10 +39,10 @@ class HistoryDatabase {
|
|||||||
onUpgrade: _upgradeDB,
|
onUpgrade: _upgradeDB,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _createDB(Database db, int version) async {
|
Future<void> _createDB(Database db, int version) async {
|
||||||
_log.i('Creating database schema v$version');
|
_log.i('Creating database schema v$version');
|
||||||
|
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE history (
|
CREATE TABLE history (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -73,16 +73,20 @@ class HistoryDatabase {
|
|||||||
copyright TEXT
|
copyright TEXT
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
// Indexes for fast lookups
|
// Indexes for fast lookups
|
||||||
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
|
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
|
||||||
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
|
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
|
||||||
await db.execute('CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)');
|
await db.execute(
|
||||||
await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)');
|
'CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_album ON history(album_name, album_artist)',
|
||||||
|
);
|
||||||
|
|
||||||
_log.i('Database schema created with indexes');
|
_log.i('Database schema created with indexes');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
|
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
|
||||||
_log.i('Upgrading database from v$oldVersion to v$newVersion');
|
_log.i('Upgrading database from v$oldVersion to v$newVersion');
|
||||||
if (oldVersion < 2) {
|
if (oldVersion < 2) {
|
||||||
@@ -95,20 +99,20 @@ class HistoryDatabase {
|
|||||||
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
|
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== iOS Path Normalization ====================
|
// ==================== iOS Path Normalization ====================
|
||||||
|
|
||||||
/// Pattern to match iOS container paths
|
/// Pattern to match iOS container paths
|
||||||
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
|
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
|
||||||
static final _iosContainerPattern = RegExp(
|
static final _iosContainerPattern = RegExp(
|
||||||
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
|
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Initialize and cache the current iOS container path
|
/// Initialize and cache the current iOS container path
|
||||||
Future<void> _initContainerPath() async {
|
Future<void> _initContainerPath() async {
|
||||||
if (!Platform.isIOS || _currentContainerPath != null) return;
|
if (!Platform.isIOS || _currentContainerPath != null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final docDir = await getApplicationDocumentsDirectory();
|
final docDir = await getApplicationDocumentsDirectory();
|
||||||
// Extract container path up to and including the UUID folder
|
// Extract container path up to and including the UUID folder
|
||||||
@@ -122,55 +126,58 @@ class HistoryDatabase {
|
|||||||
_log.w('Failed to get iOS container path: $e');
|
_log.w('Failed to get iOS container path: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize iOS file path by replacing old container UUID with current one
|
/// Normalize iOS file path by replacing old container UUID with current one
|
||||||
/// This fixes the issue where iOS changes container UUID after app updates
|
/// This fixes the issue where iOS changes container UUID after app updates
|
||||||
String _normalizeIosPath(String? filePath) {
|
String _normalizeIosPath(String? filePath) {
|
||||||
if (filePath == null || filePath.isEmpty) return filePath ?? '';
|
if (filePath == null || filePath.isEmpty) return filePath ?? '';
|
||||||
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
|
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
|
||||||
|
|
||||||
// Check if path contains an iOS container path
|
// Check if path contains an iOS container path
|
||||||
if (_iosContainerPattern.hasMatch(filePath)) {
|
if (_iosContainerPattern.hasMatch(filePath)) {
|
||||||
final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!);
|
final normalized = filePath.replaceFirst(
|
||||||
|
_iosContainerPattern,
|
||||||
|
_currentContainerPath!,
|
||||||
|
);
|
||||||
if (normalized != filePath) {
|
if (normalized != filePath) {
|
||||||
_log.d('Normalized iOS path: $filePath -> $normalized');
|
_log.d('Normalized iOS path: $filePath -> $normalized');
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Migrate iOS paths in database to use current container UUID
|
/// Migrate iOS paths in database to use current container UUID
|
||||||
/// This is called once after app update if container changed
|
/// This is called once after app update if container changed
|
||||||
Future<bool> migrateIosContainerPaths() async {
|
Future<bool> migrateIosContainerPaths() async {
|
||||||
if (!Platform.isIOS) return false;
|
if (!Platform.isIOS) return false;
|
||||||
|
|
||||||
await _initContainerPath();
|
await _initContainerPath();
|
||||||
if (_currentContainerPath == null) return false;
|
if (_currentContainerPath == null) return false;
|
||||||
|
|
||||||
final prefs = await _prefs;
|
final prefs = await _prefs;
|
||||||
final lastContainer = prefs.getString('ios_last_container_path');
|
final lastContainer = prefs.getString('ios_last_container_path');
|
||||||
|
|
||||||
if (lastContainer == _currentContainerPath) {
|
if (lastContainer == _currentContainerPath) {
|
||||||
_log.d('iOS container path unchanged, skipping migration');
|
_log.d('iOS container path unchanged, skipping migration');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('iOS container changed: $lastContainer -> $_currentContainerPath');
|
_log.i('iOS container changed: $lastContainer -> $_currentContainerPath');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|
||||||
// Get all items with iOS paths
|
// Get all items with iOS paths
|
||||||
final rows = await db.query('history', columns: ['id', 'file_path']);
|
final rows = await db.query('history', columns: ['id', 'file_path']);
|
||||||
int updatedCount = 0;
|
int updatedCount = 0;
|
||||||
final batch = db.batch();
|
final batch = db.batch();
|
||||||
|
|
||||||
for (final row in rows) {
|
for (final row in rows) {
|
||||||
final id = row['id'] as String;
|
final id = row['id'] as String;
|
||||||
final oldPath = row['file_path'] as String?;
|
final oldPath = row['file_path'] as String?;
|
||||||
|
|
||||||
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
|
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
|
||||||
final newPath = _normalizeIosPath(oldPath);
|
final newPath = _normalizeIosPath(oldPath);
|
||||||
if (newPath != oldPath) {
|
if (newPath != oldPath) {
|
||||||
@@ -184,14 +191,14 @@ class HistoryDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updatedCount > 0) {
|
if (updatedCount > 0) {
|
||||||
await batch.commit(noResult: true);
|
await batch.commit(noResult: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current container path
|
// Save current container path
|
||||||
await prefs.setString('ios_last_container_path', _currentContainerPath!);
|
await prefs.setString('ios_last_container_path', _currentContainerPath!);
|
||||||
|
|
||||||
_log.i('iOS path migration complete: $updatedCount paths updated');
|
_log.i('iOS path migration complete: $updatedCount paths updated');
|
||||||
return updatedCount > 0;
|
return updatedCount > 0;
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
@@ -199,32 +206,34 @@ class HistoryDatabase {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Migrate data from SharedPreferences to SQLite
|
/// Migrate data from SharedPreferences to SQLite
|
||||||
/// Returns true if migration was performed, false if already migrated
|
/// Returns true if migration was performed, false if already migrated
|
||||||
Future<bool> migrateFromSharedPreferences() async {
|
Future<bool> migrateFromSharedPreferences() async {
|
||||||
final prefs = await _prefs;
|
final prefs = await _prefs;
|
||||||
final migrationKey = 'history_migrated_to_sqlite';
|
final migrationKey = 'history_migrated_to_sqlite';
|
||||||
|
|
||||||
if (prefs.getBool(migrationKey) == true) {
|
if (prefs.getBool(migrationKey) == true) {
|
||||||
_log.d('Already migrated to SQLite');
|
_log.d('Already migrated to SQLite');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final jsonStr = prefs.getString('download_history');
|
final jsonStr = prefs.getString('download_history');
|
||||||
if (jsonStr == null || jsonStr.isEmpty) {
|
if (jsonStr == null || jsonStr.isEmpty) {
|
||||||
_log.d('No SharedPreferences history to migrate');
|
_log.d('No SharedPreferences history to migrate');
|
||||||
await prefs.setBool(migrationKey, true);
|
await prefs.setBool(migrationKey, true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||||
_log.i('Migrating ${jsonList.length} items from SharedPreferences to SQLite');
|
_log.i(
|
||||||
|
'Migrating ${jsonList.length} items from SharedPreferences to SQLite',
|
||||||
|
);
|
||||||
|
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final batch = db.batch();
|
final batch = db.batch();
|
||||||
|
|
||||||
for (final json in jsonList) {
|
for (final json in jsonList) {
|
||||||
final map = json as Map<String, dynamic>;
|
final map = json as Map<String, dynamic>;
|
||||||
batch.insert(
|
batch.insert(
|
||||||
@@ -233,20 +242,20 @@ class HistoryDatabase {
|
|||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await batch.commit(noResult: true);
|
await batch.commit(noResult: true);
|
||||||
|
|
||||||
// Mark as migrated but keep old data for safety
|
// Mark as migrated but keep old data for safety
|
||||||
await prefs.setBool(migrationKey, true);
|
await prefs.setBool(migrationKey, true);
|
||||||
_log.i('Migration complete: ${jsonList.length} items');
|
_log.i('Migration complete: ${jsonList.length} items');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_log.e('Migration failed: $e', e, stack);
|
_log.e('Migration failed: $e', e, stack);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert JSON format (camelCase) to DB row (snake_case)
|
/// Convert JSON format (camelCase) to DB row (snake_case)
|
||||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||||
return {
|
return {
|
||||||
@@ -278,7 +287,7 @@ class HistoryDatabase {
|
|||||||
'copyright': json['copyright'],
|
'copyright': json['copyright'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert DB row (snake_case) to JSON format (camelCase)
|
/// Convert DB row (snake_case) to JSON format (camelCase)
|
||||||
/// Also normalizes iOS paths if container UUID changed
|
/// Also normalizes iOS paths if container UUID changed
|
||||||
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
|
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
|
||||||
@@ -311,9 +320,9 @@ class HistoryDatabase {
|
|||||||
'copyright': row['copyright'],
|
'copyright': row['copyright'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== CRUD Operations ====================
|
// ==================== CRUD Operations ====================
|
||||||
|
|
||||||
/// Insert or update a history item
|
/// Insert or update a history item
|
||||||
Future<void> upsert(Map<String, dynamic> json) async {
|
Future<void> upsert(Map<String, dynamic> json) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
@@ -323,7 +332,7 @@ class HistoryDatabase {
|
|||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all history items ordered by download date (newest first)
|
/// Get all history items ordered by download date (newest first)
|
||||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
@@ -335,7 +344,7 @@ class HistoryDatabase {
|
|||||||
);
|
);
|
||||||
return rows.map(_dbRowToJson).toList();
|
return rows.map(_dbRowToJson).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get item by ID
|
/// Get item by ID
|
||||||
Future<Map<String, dynamic>?> getById(String id) async {
|
Future<Map<String, dynamic>?> getById(String id) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
@@ -348,7 +357,7 @@ class HistoryDatabase {
|
|||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
return _dbRowToJson(rows.first);
|
return _dbRowToJson(rows.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get item by Spotify ID - O(1) with index
|
/// Get item by Spotify ID - O(1) with index
|
||||||
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
|
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
@@ -361,7 +370,7 @@ class HistoryDatabase {
|
|||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
return _dbRowToJson(rows.first);
|
return _dbRowToJson(rows.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get item by ISRC - O(1) with index
|
/// Get item by ISRC - O(1) with index
|
||||||
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
|
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
@@ -374,7 +383,7 @@ class HistoryDatabase {
|
|||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
return _dbRowToJson(rows.first);
|
return _dbRowToJson(rows.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if spotify_id exists - O(1) with index
|
/// Check if spotify_id exists - O(1) with index
|
||||||
Future<bool> existsBySpotifyId(String spotifyId) async {
|
Future<bool> existsBySpotifyId(String spotifyId) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
@@ -384,42 +393,42 @@ class HistoryDatabase {
|
|||||||
);
|
);
|
||||||
return result.isNotEmpty;
|
return result.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all spotify_ids as Set for fast in-memory lookup
|
/// Get all spotify_ids as Set for fast in-memory lookup
|
||||||
Future<Set<String>> getAllSpotifyIds() async {
|
Future<Set<String>> getAllSpotifyIds() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final rows = await db.rawQuery(
|
final rows = await db.rawQuery(
|
||||||
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""'
|
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""',
|
||||||
);
|
);
|
||||||
return rows.map((r) => r['spotify_id'] as String).toSet();
|
return rows.map((r) => r['spotify_id'] as String).toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete by ID
|
/// Delete by ID
|
||||||
Future<void> deleteById(String id) async {
|
Future<void> deleteById(String id) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete('history', where: 'id = ?', whereArgs: [id]);
|
await db.delete('history', where: 'id = ?', whereArgs: [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete by Spotify ID
|
/// Delete by Spotify ID
|
||||||
Future<void> deleteBySpotifyId(String spotifyId) async {
|
Future<void> deleteBySpotifyId(String spotifyId) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
|
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all history
|
/// Clear all history
|
||||||
Future<void> clearAll() async {
|
Future<void> clearAll() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.delete('history');
|
await db.delete('history');
|
||||||
_log.i('Cleared all history');
|
_log.i('Cleared all history');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get total count
|
/// Get total count
|
||||||
Future<int> getCount() async {
|
Future<int> getCount() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
|
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
|
||||||
return Sqflite.firstIntValue(result) ?? 0;
|
return Sqflite.firstIntValue(result) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find existing item by spotify_id or isrc (for deduplication)
|
/// Find existing item by spotify_id or isrc (for deduplication)
|
||||||
Future<Map<String, dynamic>?> findExisting({
|
Future<Map<String, dynamic>?> findExisting({
|
||||||
String? spotifyId,
|
String? spotifyId,
|
||||||
@@ -428,7 +437,7 @@ class HistoryDatabase {
|
|||||||
if (spotifyId != null && spotifyId.isNotEmpty) {
|
if (spotifyId != null && spotifyId.isNotEmpty) {
|
||||||
final bySpotify = await getBySpotifyId(spotifyId);
|
final bySpotify = await getBySpotifyId(spotifyId);
|
||||||
if (bySpotify != null) return bySpotify;
|
if (bySpotify != null) return bySpotify;
|
||||||
|
|
||||||
// Check for deezer: prefix matching
|
// Check for deezer: prefix matching
|
||||||
if (spotifyId.startsWith('deezer:')) {
|
if (spotifyId.startsWith('deezer:')) {
|
||||||
final deezerId = spotifyId.substring(7);
|
final deezerId = spotifyId.substring(7);
|
||||||
@@ -442,31 +451,63 @@ class HistoryDatabase {
|
|||||||
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
|
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isrc != null && isrc.isNotEmpty) {
|
if (isrc != null && isrc.isNotEmpty) {
|
||||||
return await getByIsrc(isrc);
|
return await getByIsrc(isrc);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close database
|
/// Close database
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.close();
|
await db.close();
|
||||||
_database = null;
|
_database = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update file path for a history entry (e.g. after format conversion)
|
||||||
|
Future<void> updateFilePath(
|
||||||
|
String id,
|
||||||
|
String newFilePath, {
|
||||||
|
String? newSafFileName,
|
||||||
|
String? newQuality,
|
||||||
|
int? newBitDepth,
|
||||||
|
int? newSampleRate,
|
||||||
|
bool clearAudioSpecs = false,
|
||||||
|
}) async {
|
||||||
|
final db = await database;
|
||||||
|
final values = <String, dynamic>{'file_path': newFilePath};
|
||||||
|
if (newSafFileName != null) {
|
||||||
|
values['saf_file_name'] = newSafFileName;
|
||||||
|
}
|
||||||
|
if (newQuality != null) {
|
||||||
|
values['quality'] = newQuality;
|
||||||
|
}
|
||||||
|
if (clearAudioSpecs) {
|
||||||
|
values['bit_depth'] = null;
|
||||||
|
values['sample_rate'] = null;
|
||||||
|
} else {
|
||||||
|
if (newBitDepth != null) {
|
||||||
|
values['bit_depth'] = newBitDepth;
|
||||||
|
}
|
||||||
|
if (newSampleRate != null) {
|
||||||
|
values['sample_rate'] = newSampleRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all file paths from download history
|
/// Get all file paths from download history
|
||||||
/// Used to exclude downloaded files from local library scan
|
/// Used to exclude downloaded files from local library scan
|
||||||
Future<Set<String>> getAllFilePaths() async {
|
Future<Set<String>> getAllFilePaths() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final rows = await db.rawQuery(
|
final rows = await db.rawQuery(
|
||||||
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""'
|
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""',
|
||||||
);
|
);
|
||||||
return rows.map((r) => r['file_path'] as String).toSet();
|
return rows.map((r) => r['file_path'] as String).toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all entries with file paths for orphan detection
|
/// Get all entries with file paths for orphan detection
|
||||||
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
|
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
|
||||||
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
|
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
|
||||||
@@ -478,18 +519,24 @@ class HistoryDatabase {
|
|||||||
''');
|
''');
|
||||||
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete multiple entries by IDs
|
/// Delete multiple entries by IDs
|
||||||
Future<int> deleteByIds(List<String> ids) async {
|
Future<int> deleteByIds(List<String> ids) async {
|
||||||
if (ids.isEmpty) return 0;
|
if (ids.isEmpty) return 0;
|
||||||
|
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final placeholders = List.filled(ids.length, '?').join(',');
|
var totalDeleted = 0;
|
||||||
final count = await db.rawDelete(
|
const chunkSize = 500;
|
||||||
'DELETE FROM history WHERE id IN ($placeholders)',
|
for (var i = 0; i < ids.length; i += chunkSize) {
|
||||||
ids,
|
final end = (i + chunkSize < ids.length) ? i + chunkSize : ids.length;
|
||||||
);
|
final chunk = ids.sublist(i, end);
|
||||||
_log.i('Deleted $count orphaned entries');
|
final placeholders = List.filled(chunk.length, '?').join(',');
|
||||||
return count;
|
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 {
|
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||||
|
if (items.isEmpty) return;
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final batch = db.batch();
|
final batch = db.batch();
|
||||||
|
|
||||||
@@ -350,16 +351,46 @@ class LibraryDatabase {
|
|||||||
Future<int> cleanupMissingFiles() async {
|
Future<int> cleanupMissingFiles() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final rows = await db.query('library', columns: ['id', 'file_path']);
|
final rows = await db.query('library', columns: ['id', 'file_path']);
|
||||||
|
|
||||||
int removed = 0;
|
final missingIds = <String>[];
|
||||||
for (final row in rows) {
|
const checkChunkSize = 16;
|
||||||
final filePath = row['file_path'] as String;
|
for (var i = 0; i < rows.length; i += checkChunkSize) {
|
||||||
if (!await fileExists(filePath)) {
|
final end = (i + checkChunkSize < rows.length)
|
||||||
await db.delete('library', where: 'id = ?', whereArgs: [row['id']]);
|
? i + checkChunkSize
|
||||||
removed++;
|
: 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) {
|
if (removed > 0) {
|
||||||
_log.i('Cleaned up $removed missing files from library');
|
_log.i('Cleaned up $removed missing files from library');
|
||||||
}
|
}
|
||||||
@@ -440,14 +471,22 @@ class LibraryDatabase {
|
|||||||
Future<int> deleteByPaths(List<String> filePaths) async {
|
Future<int> deleteByPaths(List<String> filePaths) async {
|
||||||
if (filePaths.isEmpty) return 0;
|
if (filePaths.isEmpty) return 0;
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final placeholders = List.filled(filePaths.length, '?').join(',');
|
var totalDeleted = 0;
|
||||||
final result = await db.rawDelete(
|
const chunkSize = 500;
|
||||||
'DELETE FROM library WHERE file_path IN ($placeholders)',
|
for (var i = 0; i < filePaths.length; i += chunkSize) {
|
||||||
filePaths,
|
final end = (i + chunkSize < filePaths.length)
|
||||||
);
|
? i + chunkSize
|
||||||
if (result > 0) {
|
: filePaths.length;
|
||||||
_log.i('Deleted $result items from library');
|
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';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
const int _maxLogMessageLength = 500;
|
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}) {
|
String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
|
||||||
if (value.length <= maxLength) {
|
if (value.length <= maxLength) {
|
||||||
@@ -16,6 +37,33 @@ String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
|
|||||||
return '${value.substring(0, maxLength)}...[truncated]';
|
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 {
|
class LogEntry {
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
final String level;
|
final String level;
|
||||||
@@ -59,6 +107,7 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
final Queue<LogEntry> _entries = Queue<LogEntry>();
|
final Queue<LogEntry> _entries = Queue<LogEntry>();
|
||||||
Timer? _goLogTimer;
|
Timer? _goLogTimer;
|
||||||
int _lastGoLogIndex = 0;
|
int _lastGoLogIndex = 0;
|
||||||
|
bool _isFetchingGoLogs = false;
|
||||||
|
|
||||||
static bool _loggingEnabled = false;
|
static bool _loggingEnabled = false;
|
||||||
static bool get loggingEnabled => _loggingEnabled;
|
static bool get loggingEnabled => _loggingEnabled;
|
||||||
@@ -79,9 +128,11 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final sanitizedMessage = _truncateLogText(entry.message);
|
final sanitizedMessage = _truncateLogText(
|
||||||
|
_redactSensitiveText(entry.message),
|
||||||
|
);
|
||||||
final sanitizedError = entry.error != null
|
final sanitizedError = entry.error != null
|
||||||
? _truncateLogText(entry.error!)
|
? _truncateLogText(_redactSensitiveText(entry.error!))
|
||||||
: null;
|
: null;
|
||||||
final sanitizedEntry =
|
final sanitizedEntry =
|
||||||
(sanitizedMessage == entry.message && sanitizedError == entry.error)
|
(sanitizedMessage == entry.message && sanitizedError == entry.error)
|
||||||
@@ -105,13 +156,20 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
void startGoLogPolling() {
|
void startGoLogPolling() {
|
||||||
_goLogTimer?.cancel();
|
_goLogTimer?.cancel();
|
||||||
_goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
|
_goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
|
||||||
await _fetchGoLogs();
|
if (_isFetchingGoLogs) return;
|
||||||
|
_isFetchingGoLogs = true;
|
||||||
|
try {
|
||||||
|
await _fetchGoLogs();
|
||||||
|
} finally {
|
||||||
|
_isFetchingGoLogs = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void stopGoLogPolling() {
|
void stopGoLogPolling() {
|
||||||
_goLogTimer?.cancel();
|
_goLogTimer?.cancel();
|
||||||
_goLogTimer = null;
|
_goLogTimer = null;
|
||||||
|
_isFetchingGoLogs = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchGoLogs() async {
|
Future<void> _fetchGoLogs() async {
|
||||||
@@ -119,10 +177,15 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
||||||
final logs = result['logs'] as List<dynamic>? ?? [];
|
final logs = result['logs'] as List<dynamic>? ?? [];
|
||||||
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
|
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
|
||||||
|
final keepNonErrorLogs = _loggingEnabled;
|
||||||
|
|
||||||
for (final log in logs) {
|
for (final log in logs) {
|
||||||
final timestamp = log['timestamp'] as String? ?? '';
|
|
||||||
final level = log['level'] as String? ?? 'INFO';
|
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 tag = log['tag'] as String? ?? 'Go';
|
||||||
final message = log['message'] as String? ?? '';
|
final message = log['message'] as String? ?? '';
|
||||||
|
|
||||||
@@ -211,7 +274,11 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
buffer.writeln(
|
buffer.writeln(
|
||||||
'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})',
|
'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('Hardware: ${android.hardware}');
|
||||||
buffer.writeln('Product: ${android.product}');
|
buffer.writeln('Product: ${android.product}');
|
||||||
buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}');
|
buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}');
|
||||||
@@ -308,12 +375,14 @@ class BufferedOutput extends LogOutput {
|
|||||||
void output(OutputEvent event) {
|
void output(OutputEvent event) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
for (final line in event.lines) {
|
for (final line in event.lines) {
|
||||||
debugPrint(_truncateLogText(line));
|
debugPrint(_truncateLogText(_redactSensitiveText(line)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final level = _levelToString(event.level);
|
final level = _levelToString(event.level);
|
||||||
final message = _truncateLogText(event.lines.join('\n'));
|
final message = _truncateLogText(
|
||||||
|
_redactSensitiveText(event.lines.join('\n')),
|
||||||
|
);
|
||||||
|
|
||||||
LogBuffer().add(
|
LogBuffer().add(
|
||||||
LogEntry(
|
LogEntry(
|
||||||
@@ -372,6 +441,10 @@ class AppLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _addToBuffer(String level, String message, {String? error}) {
|
void _addToBuffer(String level, String message, {String? error}) {
|
||||||
|
if (!LogBuffer.loggingEnabled && level != 'ERROR' && level != 'FATAL') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
LogBuffer().add(
|
LogBuffer().add(
|
||||||
LogEntry(
|
LogEntry(
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
@@ -412,7 +485,7 @@ class AppLogger {
|
|||||||
_addToBuffer('ERROR', message, error: error.toString());
|
_addToBuffer('ERROR', message, error: error.toString());
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[$_tag] ERROR: ${_truncateLogText(message)} | ${_truncateLogText(error.toString())}',
|
'[$_tag] ERROR: ${_truncateLogText(_redactSensitiveText(message))} | ${_truncateLogText(_redactSensitiveText(error.toString()))}',
|
||||||
);
|
);
|
||||||
if (stackTrace != null) {
|
if (stackTrace != null) {
|
||||||
debugPrint(stackTrace.toString());
|
debugPrint(stackTrace.toString());
|
||||||
|
|||||||
@@ -84,83 +84,6 @@ class _KofiPainter extends CustomPainter {
|
|||||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
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 {
|
class GitHubIcon extends StatelessWidget {
|
||||||
final double size;
|
final double size;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ class BuiltInService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default quality options for built-in services (Tidal, Qobuz, YouTube)
|
/// Default quality options for built-in services (Tidal, Qobuz, Amazon, YouTube)
|
||||||
/// Note: Amazon is fallback-only and not shown in picker
|
|
||||||
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
|
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
|
||||||
const _builtInServices = [
|
const _builtInServices = [
|
||||||
BuiltInService(
|
BuiltInService(
|
||||||
@@ -44,6 +43,17 @@ const _builtInServices = [
|
|||||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
BuiltInService(
|
||||||
|
id: 'amazon',
|
||||||
|
label: 'Amazon',
|
||||||
|
qualityOptions: [
|
||||||
|
QualityOption(
|
||||||
|
id: 'LOSSLESS',
|
||||||
|
label: 'FLAC Best Available',
|
||||||
|
description: 'Amazon API delivers the best available lossless quality',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
BuiltInService(
|
BuiltInService(
|
||||||
id: 'youtube',
|
id: 'youtube',
|
||||||
label: 'YouTube',
|
label: 'YouTube',
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.6.0+77
|
version: 3.6.5+79
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user