mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abc599d7f9 | |||
| 9b27e86e0f | |||
| dbe8f5d814 | |||
| 9847594ca1 | |||
| 986f5eafc8 | |||
| 84df64fcfe | |||
| a9150b85b9 | |||
| 68e6c8be35 | |||
| bd42655c0e | |||
| fe1c96ea12 | |||
| bae2bf63eb | |||
| b6574f0097 | |||
| c35a8dd803 | |||
| d54b2249b6 | |||
| f7be2c1e12 | |||
| ebe7d87da7 | |||
| 3a6b7eed59 | |||
| 51d02d7764 | |||
| df39d61ed4 | |||
| 7ec5d28caf | |||
| 23f5aa11b0 | |||
| 5fdf1df5df | |||
| f9dd82010f | |||
| f0790b627d | |||
| 55350fffa0 | |||
| 7229602343 | |||
| 1c81c53699 | |||
| 5256d6197b | |||
| 79a6c8cdc0 | |||
| aa3b4d7d1e | |||
| cd220a4650 | |||
| d71b2a9ab8 | |||
| a2efe7243d | |||
| e0acda14e4 | |||
| 029ab8ea47 | |||
| 38f9498006 | |||
| 67fc3e5de2 | |||
| f1e6e9253f | |||
| 11c612e270 | |||
| cec5e49659 | |||
| 1dbdb5f2c3 | |||
| 086511d3e9 | |||
| 3d366d21b7 | |||
| 35f412dbd2 | |||
| c167aa0522 | |||
| fccb3f3d78 | |||
| 3a33283e94 | |||
| c74fb28a3a | |||
| ea504cc3ed | |||
| 61a2ad258e | |||
| ab62a8b1a9 | |||
| 479eb1272d | |||
| d23562e579 | |||
| 541d64bdd0 | |||
| d4f7e6e494 | |||
| 532c08fe2e | |||
| 704b9674f4 | |||
| 3de94280d2 | |||
| 65897789f6 | |||
| 5d097c3a95 | |||
| 4023e752a0 | |||
| 9a722b1a24 | |||
| 37b4727a29 | |||
| 2604d0002a | |||
| cca337ab31 | |||
| bb6e766a09 | |||
| af203ae51f | |||
| 01cbdde70e | |||
| e70ed311ed | |||
| c732cddf06 | |||
| 1f71f957e2 | |||
| 757c5fab19 | |||
| cfa537db1f | |||
| 8b18bef5ab | |||
| 76b01fb837 | |||
| 219ea593dd | |||
| 5c54e04b69 | |||
| bef07b1583 | |||
| 859762e35c | |||
| ca136b8e17 | |||
| 03d29a73f7 | |||
| c6ee9cda35 | |||
| ad3fefac0b | |||
| ad606cca53 | |||
| c0a9cb756f | |||
| 5fa00c0051 | |||
| 239e073a8c | |||
| 278ebf3472 | |||
| 7ade57e010 | |||
| 6e7c766945 | |||
| 55b457a4c0 | |||
| 65a152cada | |||
| e4a6177cb5 | |||
| 34ffbca3e8 | |||
| f8acd8f3b6 | |||
| 9956f051ac | |||
| b33ae905a2 | |||
| 11eb0aa12a | |||
| 7c08321ce3 | |||
| e20becdca7 | |||
| 24897e25e2 | |||
| 2dc4cef583 | |||
| 34c95fbd81 | |||
| 9071db9b88 | |||
| 3eb2fdd7fa | |||
| 99e0d3d361 | |||
| a2eb89e230 | |||
| b21e953ef1 | |||
| 0ef086ce57 | |||
| 72d45746a5 | |||
| 9c22f41a3e | |||
| 22f001a735 | |||
| 26d464d3c7 | |||
| 3d6a3f8d04 | |||
| 39ce22a9e2 | |||
| 88f9a65d11 | |||
| 663ee12bcc | |||
| 8c201b5b4a | |||
| 5e19178bc0 | |||
| 423695c24d |
@@ -1,4 +1,3 @@
|
||||
github: zarzet
|
||||
ko_fi: zarzet
|
||||
buy_me_a_coffee: zarzet
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2 # Need previous commit to compare
|
||||
|
||||
|
||||
@@ -60,23 +60,23 @@ jobs:
|
||||
df -h
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
ls -la
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: android-apk
|
||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||
@@ -169,17 +169,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ios/Pods
|
||||
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: build/ios/ipa/SpotiFLAC-*.ipa
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Extract changelog for version
|
||||
id: changelog
|
||||
@@ -338,13 +338,13 @@ jobs:
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./release
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
@@ -385,7 +385,7 @@ jobs:
|
||||
cat /tmp/release_body.txt
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||
@@ -403,16 +403,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./release
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
+485
-33
@@ -1,5 +1,487 @@
|
||||
# Changelog
|
||||
|
||||
## [3.6.5] - 2026-02-10
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Audio Format Conversion**: Convert between FLAC, MP3, and Opus directly from Track Metadata screen with full metadata and cover art preservation
|
||||
- **PC v7.0.8 Backend Merge**: Adopts several Go backend improvements from SpotiFLAC PC v7.0.8 including Amazon encrypted stream support, SpotFetch metadata fallback, and Qobuz API update
|
||||
- **Amazon Music Re-enabled**: Amazon provider back in service with new API
|
||||
|
||||
### Added
|
||||
|
||||
- "Use Primary Artist Only" setting: strips featured artists from folder names (e.g. "Justin Bieber, Quavo" becomes "Justin Bieber") for cleaner folder organization
|
||||
- Supports separators: `, ` `;` `&` `feat.` `ft.` `featuring` `with` `x`
|
||||
- Available in Settings > Download > below "Use Album Artist for folders"
|
||||
- Audio format conversion from Track Metadata screen
|
||||
- Convert between FLAC, MP3, and Opus formats (any direction)
|
||||
- Selectable bitrate: 128k, 192k, 256k, 320k
|
||||
- Full metadata and cover art preservation during conversion
|
||||
- Confirmation dialog before converting (original file deleted after)
|
||||
- SAF storage support: copies to temp, converts, writes back via SAF
|
||||
- Download history automatically updated with new file path
|
||||
- Unified download request contract (`DownloadRequestPayload`) for all providers/flows
|
||||
- Includes full superset fields: lyrics mode, genre/label/copyright, provider IDs, SAF params, cover/quality settings
|
||||
- Added strategy flags in payload: `use_extensions`, `use_fallback`
|
||||
- New Go unified router entrypoint: `DownloadByStrategy(requestJSON)`
|
||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||
- New backend client for `spotify.afkarxyz.fun/api`
|
||||
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||
- Includes heuristic detection of lyrics stored in Comment fields
|
||||
- Edit Metadata now supports manual cover selection (pick/replace cover image) and embeds it into audio tags on save
|
||||
- Save Lyrics now shows an immediate in-progress snackbar (`Saving lyrics...`) so users know the operation has started
|
||||
|
||||
### Changed
|
||||
|
||||
- Merged several Go backend improvements from SpotiFLAC PC v7.0.8: Amazon new API with encrypted stream/decryption support, SpotFetch metadata fallback for Spotify-blocked regions, multi-format lyrics extraction (MP3/Opus/OGG), Qobuz Jumo API update.
|
||||
- Download queue execution now builds one payload and uses a single bridge entrypoint (`PlatformBridge.downloadByStrategy`) instead of branching into multiple bridge methods
|
||||
- Dart `downloadByStrategy` now sends a single request to Go (`downloadByStrategy` channel); routing concern is centralized in Go backend
|
||||
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||
- Edit Metadata cover section moved to the top of the form and now previews current embedded cover before replacement (plus selected replacement preview)
|
||||
- Edit Metadata cover preview enlarged (120px to 160px) with shadow, side-by-side layout for current vs selected cover, and label repositioned below image
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed lyrics mode "External .LRC" still embedding lyrics into metadata - `lyrics_mode` was not being sent to Go backend for single-service downloads and YouTube provider, causing Go to default to "embed"
|
||||
- Fixed `flutter_local_notifications` v20 breaking changes - migrated all `initialize()`, `show()`, and `cancel()` calls from positional parameters to named parameters
|
||||
- Fixed SAF duplicate folder bug: concurrent batch downloads creating empty folders with `(1)`, `(2)`, `(3)` suffixes - added synchronized lock to `ensureDocumentDir` in Kotlin with duplicate detection and cleanup
|
||||
- Track Metadata lyrics section now hides "Embed Lyrics" when lyrics are already embedded in file, preventing redundant embed attempts
|
||||
- Fixed lyrics embed path to support FLAC/MP3/Opus consistently (including SAF files) without forcing unsupported parser paths
|
||||
- Inconsistent parameter parity across download paths
|
||||
- `downloadWithExtensions` now carries `copyright`
|
||||
- YouTube path now carries `embed_max_quality_cover` and metadata parity fields
|
||||
- Inconsistent success response metadata between direct/fallback flows
|
||||
- Added shared Go response builder for `DownloadTrack` and `DownloadWithFallback`
|
||||
- Success responses now consistently include `genre`, `label`, `copyright`, and `lyrics_lrc`
|
||||
- YouTube success response now also includes extended metadata fields (`cover_url`, `genre`, `label`, `copyright`) for parity with other providers
|
||||
- Fixed `Save Lyrics` crash on Android (`java.lang.Integer cannot be cast to java.lang.Long`) by normalizing `duration_ms` channel argument as `Number -> Long`
|
||||
- Fixed FLAC Re-enrich cover edge case where metadata could be written without cover when temp cover file creation failed; FLAC cover embed now uses in-memory bytes and verifies cover after write
|
||||
- Fixed FLAC picture-block embed robustness by detecting image MIME via magic bytes (JPEG/PNG/GIF/WEBP) instead of relying on filename extension
|
||||
- Fixed MP3/Opus metadata rewrite flows to preserve existing embedded cover when no new cover is available
|
||||
- Fixed Library tab cover not updating after manual cover edit/re-embed for downloaded tracks
|
||||
- Queue/Library now prefers embedded cover art extracted from local files (not just cached `coverUrl`)
|
||||
- Added per-track extraction cache with file-modification invalidation so updated embedded art is reflected in Library
|
||||
- Extraction is now on-demand for edited tracks only (not full-library reload)
|
||||
- Returning from Track Metadata now refreshes cover cache only for the affected track
|
||||
- Cover refresh is now skipped when file modification time is unchanged, removing unnecessary flash when simply opening/closing metadata screen
|
||||
- Fixed repeated cover preview extraction in Track Metadata screen (`track_cover_preview_*`) causing visible flash when reopening
|
||||
- Added in-memory preview cache keyed by file path so reopening metadata reuses existing preview without re-extract
|
||||
- Cache validation uses file modification time for filesystem paths; SAF paths are refreshed only after successful edit actions
|
||||
- Queue/Library now also compares SAF file last-modified (`getSafFileModTimes`) before refreshing embedded-cover cache
|
||||
- Preview cache key is now stable per track item (not volatile temp SAF path), eliminating false cache misses on SAF-backed files
|
||||
- Track Metadata no longer auto-extracts cover preview on every screen open; extraction now runs only after actual edit/re-enrich changes (or when explicitly forced)
|
||||
- Track metadata edits/re-enrich now sync updated tags back into `downloadHistoryProvider` + SQLite history rows
|
||||
- Non-Library screens that read download history (Home/album/history views) now reflect updated title/artist/album/tags without manual rescan
|
||||
- Track Metadata back-navigation now returns an explicit update result after successful edits/re-enrich, enabling History-tab cover refresh fallback when SAF timestamps are unreliable
|
||||
|
||||
### Performance
|
||||
|
||||
- Configured Flutter image cache limits (240 entries / 60 MiB) and added `ResizeImage` wrappers for cover art precaching across all screens, reducing peak memory usage on cover-heavy pages
|
||||
- Added LRU eviction to Deezer cache with configurable max entries per cache type (search/album/artist/ISRC) and periodic expired-entry cleanup to prevent unbounded memory growth in long sessions
|
||||
- Download progress notifications are now normalized (2-decimal progress, 1-decimal speed, 0.1 MiB byte steps) and deduplicated by track/artist/percent/queue-count, reducing notification overhead during batch downloads
|
||||
- Each queue item now uses a dedicated `ConsumerWidget` with per-item `.select()` instead of rebuilding the entire list on any item change; items are wrapped in `RepaintBoundary` for paint isolation
|
||||
- Queue/Library search indexes are now built on-demand per item instead of upfront for all items, with bounded LRU caches (max 4000 entries)
|
||||
- `copyWith` now preserves derived lookup indexes (ISRC map, track key set) when items list is unchanged, avoiding O(n) rebuild on every scan progress update
|
||||
- Scan progress polling now compares values before calling `setState`, skipping unnecessary widget rebuilds when nothing changed
|
||||
- Added in-flight flag to download progress and library scan polling to prevent concurrent timer callbacks from overlapping
|
||||
- New `DownloadedEmbeddedCoverResolver` service replaces per-screen cover extraction logic with a shared bounded cache (160 entries), mod-time validation, and throttled refresh checks
|
||||
- Multiple embedded cover change callbacks are now coalesced into a single frame via `addPostFrameCallback`, preventing redundant rebuilds
|
||||
- Downloaded album screen now caches filtered/sorted track lists and reuses them when the source data reference is unchanged
|
||||
- Home tab recent downloads now use single-pass aggregation instead of building full per-album lists, and store only IDs instead of full item objects for the clear-all action
|
||||
- Removed duplicate `_downloadedSpotifyIds` Set and `_isrcSet` (both now use existing map lookups), removed unused `_isTyping` state in home tab
|
||||
- Track cache pre-warming is now capped at 80 tracks per request to avoid excessive backend calls on large playlists
|
||||
- About page contributor avatars now use `memCacheWidth`/`memCacheHeight` to decode at display size instead of full resolution
|
||||
- Orphaned download cleanup now checks file existence in parallel (chunk 16) instead of sequentially
|
||||
- Local library `findByTrackAndArtist` now uses O(1) map lookup (`_byTrackKey`) instead of O(n) linear scan
|
||||
- Local library database load and SharedPreferences fetch now run in parallel
|
||||
- Legacy mod-time backfill now uses chunked parallel `File.stat` (chunk 24) with per-chunk cancel check
|
||||
- Downloaded album screen now caches disc grouping, sorted disc numbers, common quality, and embedded cover path with reference-identity invalidation
|
||||
- Local album screen common quality is now computed once during cache rebuild instead of per-build
|
||||
- Batch delete in album screens now uses O(1) map lookup (`tracksById`) instead of `.where().firstOrNull`
|
||||
- Cache management page now fires all async init calls in parallel and uses chunked async directory deletion (chunk 24)
|
||||
- Cover resolver preview file existence check is now throttled (2.2s interval) to reduce synchronous I/O in build path
|
||||
- History and library database DELETE operations are now chunked (500 per batch) to stay within SQLite variable limits
|
||||
- Library database `cleanupMissingFiles` now checks file existence in parallel (chunk 16) and deletes in batched SQL
|
||||
|
||||
### Security
|
||||
|
||||
- All logs (Go and Dart) now automatically redact Bearer tokens, access/refresh tokens, client secrets, API keys, and passwords using regex-based sanitization before storage
|
||||
- Extension auth URLs are now validated for HTTPS-only, no embedded credentials, and no private/local network targets before opening
|
||||
- Auth URLs in logs are summarized to scheme+host+path only (query params stripped) to prevent token leakage; token exchange error bodies are truncated and sanitized
|
||||
- Extension HTTP requests now block URLs with embedded credentials (`user:pass@host`)
|
||||
- Extension storage files changed from `0644` to `0600` (owner-only read/write)
|
||||
- All SAF relative directory paths are now sanitized per-segment with `.`/`..` filtering; all user-provided file names pass through `sanitizeFilename()` before use
|
||||
- Extension ID is sanitized before building download destination path
|
||||
- Log export device info now shows Build ID and Security Patch level instead of masked Device ID
|
||||
|
||||
### Technical
|
||||
|
||||
- Centralized request serialization in `PlatformBridge` via shared invoke helper and unified payload model
|
||||
- Go strategy router normalizes incoming service casing before dispatch
|
||||
- Extension runtime: `customSearch` now passes query/options via VM globals instead of string interpolation, preventing parser edge cases on certain devices
|
||||
- Extension runtime: JS panic handler now logs full stack trace for easier debugging
|
||||
- `DownloadQueueLookup` expanded with `byItemId` map and `itemIds` list for O(1) queue item access from UI
|
||||
- Non-error/non-fatal log entries are now skipped entirely (not just hidden) when detailed logging is disabled, reducing buffer growth and Go log polling overhead
|
||||
|
||||
### Removed
|
||||
|
||||
- Buy Me a Coffee references removed from donate page, FUNDING.yml, README, and all localization files (account suspended)
|
||||
|
||||
---
|
||||
|
||||
## [3.6.0] - 2026-02-09
|
||||
|
||||
### Highlights
|
||||
|
||||
- **YouTube Provider (Lossy)**: New download option via Cobalt API for tracks not available on lossless services
|
||||
- Opus 256kbps (recommended) or MP3 320kbps quality options
|
||||
- Full metadata embedding: cover art, title, artist, album, track/disc number, year, ISRC
|
||||
- Lyrics fetching from lrclib.net with embed and external .lrc support
|
||||
- Works as fallback when Tidal/Qobuz/Amazon downloads fail
|
||||
- **Edit Metadata**: Edit embedded metadata directly from the Track Metadata screen (FLAC, MP3, Opus)
|
||||
- Editable fields: Title, Artist, Album, Album Artist, Date, Track#, Disc#, Genre, ISRC
|
||||
- Advanced fields: Label, Copyright, Composer, Comment
|
||||
- FLAC: native Go writer, MP3/Opus: FFmpeg-based writer
|
||||
- UI refreshes in-place after save without needing to re-open the screen
|
||||
- iOS and Android support
|
||||
|
||||
### Added
|
||||
|
||||
- Save Cover Art: download high-quality album art as standalone .jpg from track metadata screen
|
||||
- Save Lyrics (.lrc): fetch and save lyrics as standalone .lrc file without downloading the song
|
||||
- Re-enrich Metadata: re-embed metadata, cover art, and lyrics into existing audio files without re-downloading (FLAC native, MP3/Opus via FFmpeg)
|
||||
- Re-enrich now supports local library items: searches Spotify/Deezer by track name + artist to fetch complete metadata from the internet, then embeds cover art, lyrics, genre, label, and all tags into the file
|
||||
- YouTube download provider using Cobalt API with SongLink/Odesli integration for Spotify/Deezer ID → YouTube URL conversion
|
||||
- SpotubeDL as fallback Cobalt proxy when primary API fails
|
||||
- YouTube video ID detection for YT Music extension compatibility
|
||||
- Parallel cover art and lyrics fetching during YouTube download
|
||||
- Queue progress now shows "X.X MB" instead of "0%" for streaming downloads where total size is unknown (Cobalt tunnel mode)
|
||||
- Full metadata pipeline for YouTube downloads: cover art, lyrics, title, artist, album, track#, disc#, year, ISRC
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed Tidal HIGH (lossy AAC) quality option - use YouTube provider for lossy downloads instead
|
||||
- Simplified download service picker by removing dead lossy format code
|
||||
- Removed Amazon from download settings UI (now only used as automatic fallback)
|
||||
- Cleaned up dead disabled-chip code in download service selector
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `error.api.youtube.login` by using YouTube Music URLs instead of regular YouTube URLs for Cobalt requests
|
||||
- Fixed SongLink to prioritize `youtubeMusic` platform URL over `youtube` for Cobalt compatibility
|
||||
- Fixed YouTube metadata not being overwritten by setting `DisableMetadata: true` in Cobalt requests
|
||||
- Fixed ISRC validation in metadata enrichment flow - invalid ISRCs no longer trigger failed Deezer lookups
|
||||
- Fixed YouTube metadata enrichment to work like other providers (SongLink Deezer ID extraction, proper metadata embedding)
|
||||
- Go metadata parsers now read Composer, Comment, Label, Copyright from FLAC, MP3 (ID3v2.2/v2.3/v2.4), and Opus/OGG files
|
||||
- Added proper COMM frame parser for ID3v2 (handles language code + description prefix correctly)
|
||||
- Fixed Re-enrich Metadata failing on SAF storage files (`content://` URIs) - Kotlin now copies SAF file to temp, Go processes temp file, then writes back for FLAC or returns temp path for FFmpeg (MP3/Opus)
|
||||
- Fixed Save Cover Art and Save Lyrics crashing on SAF-stored download history items - now saves to temp then writes to SAF tree via `createSafFileFromPath`
|
||||
- Fixed `_getFileDirectory()` crash when called with `content://` URI by adding SAF guard
|
||||
- Fixed `readAudioMetadata` Kotlin handler not handling SAF URIs - now copies to temp for reading
|
||||
- Added metadata summary log in Re-enrich flow showing all fields before embedding (title, artist, album, track#, disc#, date, ISRC, genre, label)
|
||||
|
||||
---
|
||||
|
||||
## [3.5.3] - 2026-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- CSV import flow now includes a new option: **Skip already downloaded songs** before enqueueing tracks
|
||||
- Added regression test suite for cross-script matching behavior in Go backend (`go_backend/matching_test.go`)
|
||||
|
||||
### Changed
|
||||
|
||||
- CSV import confirmation dialog now supports filtering out tracks already present in download history (matched by Spotify ID and ISRC)
|
||||
- CSV import enqueue feedback now reports added/skipped counts when duplicate downloads are skipped
|
||||
- Home search now prioritizes **Recent Access** when search field is focused with empty input, even if old search results still exist in memory
|
||||
- Search filter/result sections are now hidden while Recent Access mode is active to avoid stale-result overlap
|
||||
- Recent Access now shows a localized empty-state message when no recent items are available
|
||||
- Normalized collapsing AppBar top inset across iOS/Android so header height/animation stays visually consistent on Apple devices
|
||||
- Storage & Cache UX improved: `Clear all cache` now preserves web/runtime cache by default (optional), with explicit warnings/actions for runtime cache resets
|
||||
- Local library settings now include a display count for tracks excluded because they already exist in download history
|
||||
- Responsive layout tuning applied across key screens to reduce hardcoded-height overflow issues on smaller devices
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed false-positive cross-script matching in Qobuz/Tidal where unrelated titles/artists in different scripts could be incorrectly accepted
|
||||
- Cross-script title/artist matching now requires transliteration-aware normalization and strict similarity checks instead of auto-accepting script differences
|
||||
- Qobuz metadata fallback no longer scans all results when zero title matches are found; title verification is now required
|
||||
- Qobuz metadata final validation now rejects results when title does not match expected track name
|
||||
- Fixed Home search regression where Recent Access panel could disappear after previous searches
|
||||
- Fixed Local Library card/layout crash caused by `Flex` usage under unbounded height constraints
|
||||
- Hardened FFmpeg metadata embedding temp-file naming to prevent rare collisions during parallel downloads/fallback flows (Qobuz → Tidal) that could cause missing embedded metadata
|
||||
- Fixed SAF external lyrics naming where some providers saved `.lrc` files as `.lrc.txt`; LRC export now uses neutral MIME to preserve `.lrc` extension
|
||||
|
||||
## [3.5.2] - 2026-02-08
|
||||
|
||||
### Performance
|
||||
|
||||
- Home tab search result sections are now virtualized with `SliverList` (lazy item build) instead of eager `Column` rendering, reducing frame drops on large result sets
|
||||
- Home tab now narrows Riverpod subscriptions using field-level `select(...)` for search/provider state to reduce unnecessary full-tab rebuilds
|
||||
- Search provider dropdown now watches only required fields (`searchProvider`, `metadataSource`, `extensions`) instead of full provider states
|
||||
- Track row rendering in Home search now receives precomputed thumbnail sizing/local-library flags from parent to avoid repeated per-item provider watches
|
||||
- Removed thumbnail `debugPrint` calls inside track row `build()` to reduce runtime overhead during scrolling/rebuilds
|
||||
- Queue tab root subscription no longer watches full queue item list; it now watches only queue presence (`items.isNotEmpty`) to avoid full Library UI rebuilds on every progress tick
|
||||
- Queue download header/list rendering has been isolated into dedicated `Consumer` slivers; header now watches only queue length (`items.length`) while item list watches queue item updates
|
||||
- Queue filter/sort computations are now centralized and memoized per filter mode within a build pass (`all`/`albums`/`singles`), reducing repeated list transforms for chip counts and page content
|
||||
- Selection bottom bar content is now computed only when selection mode is active, removing hidden-state heavy list preparation
|
||||
- File existence checks in queue/library rows now use per-path `ValueNotifier` + `ValueListenableBuilder` updates instead of triggering global `setState`, reducing unnecessary whole-tab repaints
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced date range filter with sorting options in Library tab: Latest, Oldest, A-Z, Z-A
|
||||
- Sorting applies to all views: unified items, downloaded albums, and local library albums
|
||||
- Local library items now use file modification time (`fileModTime`) for sorting instead of scan time, providing more accurate chronological ordering
|
||||
- Removed redundant manual "Export Failed Downloads" button from Library UI (auto-export setting in Settings is sufficient)
|
||||
- Library filters (quality, format, source) now correctly apply to album tabs and update tab chip counts (All/Albums/Singles)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed local library scan crashing on Samsung One UI devices due to MediaStore URI mismatch in SAF tree traversal
|
||||
- Added MediaStore URI fallback in SAF file reader: when SAF permission is denied for Samsung-returned MediaStore URIs, automatically retries using READ_MEDIA_AUDIO permission
|
||||
- Hardened SAF scan with per-directory and per-file error handling: scan now skips problematic files instead of aborting entirely
|
||||
- Added visited directory tracking to prevent infinite loops from circular SAF references
|
||||
- Fixed metadata enrichment cascading failure after one queued download fails: metadata APIs (Deezer, SongLink, Spotify) now use isolated `metadataTransport` so failed download connections cannot poison metadata requests
|
||||
- Added immediate connection cleanup on every download failure path (error response and exception), not only periodic cleanup every N downloads
|
||||
- Fixed incremental SAF scan edge case where `lastModified()` failure could misclassify existing files as removed (`removedUris`)
|
||||
- Fixed tracks marked "In Library" still showing active download button - download button now shows as completed (checkmark) for local library tracks across all screens (album, playlist, artist, home/search)
|
||||
- Fixed FFmpeg M4A-to-FLAC conversion erroneously triggered on already-existing FLAC files when re-downloading duplicates via Tidal
|
||||
- Fixed SAF download creating empty artist/album folders when re-downloading duplicate tracks; directory is now only created after confirming the file does not already exist
|
||||
|
||||
## [3.5.1] - 2026-02-08
|
||||
|
||||
### Performance
|
||||
|
||||
- Removed PaletteService (palette_generator) from all screens for faster navigation and reduced memory usage
|
||||
- Album, Playlist, Downloaded Album, Local Album, and Track Metadata screens now use blurred cover art as header background instead of dominant color extraction
|
||||
- Removed `palette_generator` dependency
|
||||
- App startup now renders immediately (`runApp`) while service initialization runs asynchronously in eager init
|
||||
- Main shell provider subscriptions now use field-level `select(...)` to reduce unnecessary rebuilds
|
||||
- Settings persistence now uses single-flight + queued save coalescing to avoid redundant disk writes
|
||||
- Progress polling cadence adjusted to 800ms for download queue, local library scan progress, and Go log polling
|
||||
- Android foreground download service progress updates are throttled (change-based updates + 5s heartbeat)
|
||||
- SAF history repair is now batched (`20` items per batch) and capped per launch (`60`) to reduce startup I/O spikes
|
||||
- Incremental library scan now builds final item list in-memory instead of reloading from database
|
||||
- Local cover images in queue/library use direct `Image.file` with `errorBuilder` instead of `FutureBuilder` existence check
|
||||
- CSV parser `_parseLine` rewritten: correct escaped-quote handling, no quote characters in output
|
||||
- Removed unused legacy screen files (`home_screen.dart`, `queue_screen.dart`, `settings_screen.dart`, `settings_tab.dart`)
|
||||
- Incremental local library scan now merges delta results in-memory and sorts once, avoiding full-state reload churn
|
||||
- Queue local cover rendering now uses direct `Image.file` + `errorBuilder` (removed repeated async file-exists checks)
|
||||
|
||||
### Added
|
||||
|
||||
- Auto-cleanup orphaned downloads on history load (files that no longer exist are automatically removed from history)
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed legacy screen files that were no longer used after the tab/part refactor:
|
||||
- `lib/screens/home_screen.dart`
|
||||
- `lib/screens/queue_screen.dart`
|
||||
- `lib/screens/settings_screen.dart`
|
||||
- `lib/screens/settings_tab.dart`
|
||||
- Concurrent download limit increased from `3` to `5` (settings clamp + Options UI chips now support `1..5`)
|
||||
- Download queue now uses a single parallel scheduler path; `1` concurrency is handled as parallel-with-limit-1 (no separate sequential engine)
|
||||
- Download queue now listens to settings updates in real-time so concurrency/output settings stay in sync while queue is active
|
||||
|
||||
### Fixed
|
||||
|
||||
- CSV parser now correctly handles escaped quotes (`""`) inside quoted fields during import
|
||||
- Fixed dynamic concurrency update during active downloads: changing limit (e.g. `1 -> 3`) now schedules additional queued items without waiting current active item to finish
|
||||
- Queue scheduler now re-checks capacity/queued items on short intervals to avoid blocking on long-running single active download
|
||||
|
||||
### Dependencies
|
||||
|
||||
#### Flutter
|
||||
- `flutter_local_notifications` 19.x → 20.0.0 (breaking: all positional params converted to named params)
|
||||
- `connectivity_plus` 6.x → 7.0.0
|
||||
- `flutter_secure_storage` 9.x → 10.0.0
|
||||
- Removed `palette_generator` dependency
|
||||
|
||||
#### Go
|
||||
- `go-flac/go-flac` v1.0.0 → v2.0.4
|
||||
- `go-flac/flacvorbis` v0.2.0 → v2.0.2
|
||||
- `go-flac/flacpicture` v0.3.0 → v2.0.2
|
||||
- Go toolchain 1.24 → 1.25.7
|
||||
|
||||
#### Android
|
||||
- Android Gradle Plugin 8.x → 9.0.0
|
||||
- Kotlin 2.1.x → 2.3.10
|
||||
- `desugar_jdk_libs` → 2.1.5
|
||||
- `kotlinx-coroutines-android` → 1.10.2
|
||||
- `lifecycle-runtime-ktx` → 2.10.0
|
||||
- `activity-ktx` → 1.12.3
|
||||
|
||||
#### CI/CD
|
||||
- `actions/cache` v4 → v5
|
||||
- `actions/checkout` v4 → v6
|
||||
- `actions/setup-go` v5 → v6
|
||||
- `actions/setup-java` v4 → v5
|
||||
- `softprops/action-gh-release` v1 → v2
|
||||
- GitHub artifact actions updated
|
||||
|
||||
---
|
||||
|
||||
## [3.5.0] - 2026-02-07
|
||||
|
||||
### Highlights
|
||||
|
||||
- **SAF Storage (Android 10+)**: Proper Storage Access Framework support for download destination (content URIs)
|
||||
- Select download folder via SAF tree picker
|
||||
- Downloads now write to SAF file descriptors (`/proc/self/fd/*`) instead of raw filesystem paths
|
||||
- Works around Android 10+ scoped storage permission errors
|
||||
- **Modern Onboarding Experience**: Completely redesigned Setup and Tutorial screens
|
||||
|
||||
### Added
|
||||
|
||||
- Home feed disk caching via SharedPreferences for instant restore on app startup
|
||||
- SAF display path resolver in native Android layer (converts tree URIs to readable paths)
|
||||
- New settings fields for storage mode + SAF tree URI
|
||||
- SAF platform bridge methods: pick tree, stat/exists/delete, open content URI, copy to temp, write back to SAF
|
||||
- SAF library scan mode (DocumentFile traversal + metadata read)
|
||||
- Incremental library scanning for filesystem and SAF paths (only scans new/modified files and detects removed files)
|
||||
- Force Full Scan action in Library Settings to rescan all files on demand
|
||||
- Downloaded files are now excluded from Local Library scan results to prevent duplicate entries
|
||||
- Legacy library rows now support `file_mod_time` backfill before incremental scans (faster follow-up scans after upgrade)
|
||||
- Library UI toggle to show SAF-repaired history items
|
||||
- Scan cancelled banner + retry action for library scans
|
||||
- Android DocumentFile dependency for SAF operations
|
||||
- Post-processing API v2 (SAF-aware, ready to replace v1)
|
||||
- Donate page in Settings with Ko-fi and Buy Me a Coffee links
|
||||
- Per-App Language support on Android 13+ (locale_config.xml)
|
||||
- Interactive tutorial with working search bar simulation and clickable download buttons
|
||||
- Tutorial completion state is persisted after onboarding
|
||||
- Visual feedback animations for page transitions, entrance effects, and feature lists
|
||||
- New dedicated welcome step in setup wizard with improved branding
|
||||
|
||||
### Changed
|
||||
|
||||
- Download pipeline supports `output_path` + `output_ext` for Go backend
|
||||
- Tidal/Qobuz/Amazon/Extension downloads use SAF-aware output when enabled
|
||||
- Post-processing hooks run for SAF content URIs (via temp file bridge)
|
||||
- File operations in Library/Queue/Track screens now SAF-aware (`open`, `exists`, `delete`, `stat`)
|
||||
- Local Library scan defaults to incremental mode; full rescan is available via Force Full Scan
|
||||
- Local library database upgraded to schema v3 with `file_mod_time` tracking for incremental scan cache
|
||||
- Platform channels expanded with incremental scan APIs (`scanLibraryFolderIncremental`) on Android and iOS
|
||||
- Android platform channel adds `getSafFileModTimes` for SAF legacy cache backfill
|
||||
- Android build tooling upgraded to Gradle 9.3.1 (wrapper)
|
||||
- Android build path validated with Java 25 (Gradle/Kotlin/assemble debug)
|
||||
- SAF tree picker flow in `MainActivity` migrated to Activity Result API (`registerForActivityResult`)
|
||||
- `MainActivity` host migrated to `FlutterFragmentActivity` for SAF picker compatibility
|
||||
- Legacy `startActivityForResult` / `onActivityResult` SAF picker path removed
|
||||
- Setup screen UI polish: smaller logo, thin outline borders on text fields
|
||||
- Removed support section from About page (moved to Donate page)
|
||||
- Qobuz squid.wtf region fallback for blocked regions
|
||||
- Setup screen converted to PageView flow with animated progress bar and modern card layouts
|
||||
- Tutorial screen aligned with Setup Screen design, updated typography and softened UI shapes
|
||||
- Larger, more accessible navigation buttons for onboarding flow
|
||||
- Reduced visual noise by removing unnecessary glow effects
|
||||
|
||||
### Fixed
|
||||
|
||||
- Android 10+ `permission denied` when writing to `/storage/emulated/0` (now handled via SAF)
|
||||
- SAF history repair: auto-resolve missing content URIs using tree + filename
|
||||
- SAF download fallback: retry in app-private storage when SAF write fails
|
||||
- Tidal DASH manifest writing when output path is a file descriptor (no invalid `.m4a` path)
|
||||
- External LRC output in SAF mode
|
||||
- Restored old-device renderer fallback while using `FlutterFragmentActivity` by injecting shell args from a custom `FlutterFragment` (`--enable-impeller=false` on problematic devices)
|
||||
- Preserved Flutter fragment creation behavior (cached engine, engine group, new engine) while adding Impeller fallback support
|
||||
- SAF tree picker result now consistently returns `tree_uri` payload with persisted URI permission handling
|
||||
- SAF share file now copies to temp before sharing (fixes share from SAF content URI)
|
||||
- Home feed not updating after installing extension with homeFeed capability (no longer requires app restart)
|
||||
- Library scan hero card showing 0 tracks during scan (now shows scanned file count in real-time)
|
||||
- Library folder picker no longer requires MANAGE_EXTERNAL_STORAGE on Android 10+ (uses SAF tree picker)
|
||||
- One-time SAF migration prompt for users updating from pre-SAF versions
|
||||
- Fixed `fileModTime` propagation across Go/Android/Dart so incremental scan cache is stored and reused correctly
|
||||
- Fixed SAF incremental scan key mismatch (`lastModified` vs `fileModTime`) and normalized result fields (`skippedCount`, `totalFiles`)
|
||||
- Fixed incremental scan progress when all files are skipped (`scanned_files` now reaches total files)
|
||||
- Removed duplicate `"removeExtension"` branch in Android method channel handler (eliminates Kotlin duplicate-branch warning)
|
||||
|
||||
---
|
||||
|
||||
## [3.4.2] - 2026-02-04
|
||||
|
||||
### Improved
|
||||
|
||||
- **Mobile Network Reliability**: All providers (Qobuz, Tidal, Amazon, Deezer) now have retry logic with exponential backoff
|
||||
- Increased API timeouts: 15s → 25s (Deezer, Qobuz, Tidal), 30s (Amazon)
|
||||
- Up to 3 retry attempts per API call (500ms → 1s → 2s backoff)
|
||||
- Retryable: timeout, connection reset/refused, EOF, HTTP 5xx, HTTP 429
|
||||
- **SongLink ID Extraction**: Extract QobuzID/TidalID directly from SongLink URLs
|
||||
- New fields in `TrackAvailability`: `QobuzID`, `TidalID`
|
||||
- Qobuz/Tidal now use direct Track ID from SongLink instead of re-parsing URLs
|
||||
- **Qobuz Download Flow**: New Strategy 3 - get QobuzID from SongLink before ISRC search
|
||||
- Cache hit now uses `GetTrackByID()` directly instead of searching again
|
||||
- Pre-warm cache tries SongLink first before direct ISRC search
|
||||
- **Tidal Download Flow**: Use `availability.TidalID` directly from SongLink struct
|
||||
|
||||
---
|
||||
|
||||
## [3.4.1] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- Metadata Priority order now persists after app restart
|
||||
- Download Provider Priority order now persists after app restart
|
||||
|
||||
---
|
||||
|
||||
## [3.4.0] - 2026-02-03
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Local Library Scanning** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
|
||||
- **Duplicate Detection** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
|
||||
- **Unified Library Tab**: History renamed to Library, shows Downloaded + Local Library tracks with source badges
|
||||
|
||||
### Added
|
||||
|
||||
- Local Album Screen with cover art, disc grouping, and selection mode
|
||||
- Albums tab shows local library albums with folder icon badge
|
||||
- Singles filter includes local library singles
|
||||
- Advanced library filters: Source, Quality, Format, Date
|
||||
- Cover art extraction from embedded tags (FLAC, MP3, Opus/Ogg)
|
||||
- "Already in Library" notification when downloading existing tracks
|
||||
- Spotify secrets now stored in secure storage (`flutter_secure_storage`)
|
||||
- **Multi-Service Link Support**: Share links from Deezer, Tidal, and YouTube Music (in addition to Spotify)
|
||||
- Deezer: Full support for track, album, playlist, artist links
|
||||
- Tidal: Track links converted via SongLink to Spotify/Deezer for metadata
|
||||
- YouTube Music: Handled via ytmusic extension URL handler
|
||||
- Local library tracks now open metadata screen on tap
|
||||
|
||||
### Changed
|
||||
|
||||
- Extension HTTP sandbox enforces HTTPS and blocks private IPs
|
||||
- Extension file sandbox validates paths with boundary-safe checks
|
||||
|
||||
### Fixed
|
||||
|
||||
- Search filter bar now only appears after results load, not during loading
|
||||
- MP3/Ogg metadata parsing (ID3v2 extended headers, Ogg packet reassembly)
|
||||
- Library scan metadata (ISRC, disc number, release date)
|
||||
- Cover cache robustness (size + mtime cache key)
|
||||
- Local library selection and delete in list/grid views
|
||||
- Albums/Singles count includes local library items
|
||||
|
||||
---
|
||||
|
||||
## [3.3.6] - 2026-02-02
|
||||
|
||||
### Added
|
||||
|
||||
- **WiFi-Only Download Mode**: Pause downloads on mobile data, auto-resume on WiFi (Settings > Download > Download Network)
|
||||
- Added `connectivity_plus: ^6.0.3` dependency
|
||||
|
||||
---
|
||||
|
||||
## [3.3.5] - 2026-02-01
|
||||
|
||||
Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
@@ -30,7 +512,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
|
||||
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
|
||||
- **Album/Playlist Search**: Deezer search now includes albums and playlists
|
||||
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re*Index.(ot_inc))
|
||||
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re\*Index.(ot_inc))
|
||||
- **Optional All Files Access**: Android 13+ no longer requires full storage access; enable in Settings if needed
|
||||
- **Improved VPN Compatibility**: Better HTTP/2 support for users behind VPN or restricted networks
|
||||
|
||||
@@ -131,31 +613,26 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- Perfect for players like Samsung Music that prefer external .lrc files
|
||||
- LRC files include metadata headers (title, artist, by:SpotiFLAC-Mobile)
|
||||
- Works with all download services (Tidal, Qobuz, Amazon)
|
||||
|
||||
- **CSV Import Quality Selection**: Choose audio quality when importing CSV playlists
|
||||
- Quality picker now appears before adding CSV tracks to download queue
|
||||
- Select between FLAC qualities (Lossless, Hi-Res, Hi-Res Max) or MP3
|
||||
- Respects "Ask quality before download" setting - uses default quality if disabled
|
||||
|
||||
- **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory
|
||||
- Cover images no longer disappear when app is closed or device restarts
|
||||
- Cache stored in `app_flutter/cover_cache/` directory (not cleared by system)
|
||||
- Maximum 1000 images cached for up to 365 days
|
||||
- Covers are cached when displayed in History, Home, Album, Artist, or any other screen
|
||||
- New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management
|
||||
|
||||
- **Extended Metadata from Deezer Enrichment**: Track downloads now include label, copyright, and genre metadata from Deezer
|
||||
- New fields in `ExtTrackMetadata`: `label`, `copyright`, `genre`
|
||||
- Metadata fetched during `enrichTrack()` via Deezer album API
|
||||
- Embedded as FLAC Vorbis comments: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT`
|
||||
- Works for both extension downloads and built-in provider downloads (Tidal, Qobuz, Amazon)
|
||||
|
||||
- **Track Metadata Screen Extended Info**: Genre, label, and copyright now displayed in track metadata screen
|
||||
- Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model
|
||||
- Metadata is stored in download history and persists across app restarts
|
||||
- New localization strings: `trackGenre`, `trackLabel`, `trackCopyright`
|
||||
|
||||
- **`utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings
|
||||
- `**utils.randomUserAgent()` for Extensions\*\*: New utility function for extensions to get random browser User-Agent strings
|
||||
- Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0`
|
||||
- Useful for extensions that need to rotate User-Agents to avoid detection
|
||||
|
||||
@@ -164,7 +641,6 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- **Portuguese Language Bug**: Fixed locale parsing for languages with country codes (e.g., pt_PT, es_ES)
|
||||
- App now correctly loads Portuguese and Spanish translations
|
||||
- Updated Portuguese label to "Português (Brasil)"
|
||||
|
||||
- **VM Race Condition Panic**: Fixed `panic during execution: runtime error: index out of range [-2]` crash when switching search providers
|
||||
- Root cause: Goja VM was being accessed concurrently by multiple goroutines without synchronization
|
||||
- Added `VMMu sync.Mutex` to `LoadedExtension` struct
|
||||
@@ -173,16 +649,13 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- `EnrichTrack`, `CheckAvailability`, `GetDownloadURL`, `Download`
|
||||
- `CustomSearch`, `HandleURL`, `MatchTrack`, `PostProcess`
|
||||
- Prevents race conditions when rapidly switching between extension search providers
|
||||
|
||||
- **Tidal Release Date Fallback**: Fixed missing release date in FLAC metadata when downloading from Tidal
|
||||
- Now uses Tidal API's release date when `req.ReleaseDate` is empty
|
||||
- Ensures release date is always embedded in downloaded files
|
||||
|
||||
- **Extended Metadata for M4A→FLAC Conversion**: Fixed genre, label, and copyright not being embedded when converting Amazon M4A to FLAC
|
||||
- Flutter now extracts extended metadata from Go backend response
|
||||
- Passes `genre`, `label`, `copyright` parameters to `_embedMetadataAndCover()`
|
||||
- Tags correctly embedded during FFmpeg conversion
|
||||
|
||||
- **Extended Metadata for MP3 Conversion**: Genre, label, and copyright now embedded in MP3 files when converting from FLAC
|
||||
- Added `genre`, `label`, `copyright` parameters to `_embedMetadataToMp3()`
|
||||
- Tags embedded as ID3v2: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT`
|
||||
@@ -243,7 +716,6 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- `go_backend/httputil.go`: Updated `getRandomUserAgent()` to use modern Chrome versions
|
||||
- `go_backend/tidal.go`: Added release date fallback logic
|
||||
- `go_backend/exports.go`: Added `Genre`, `Label`, `Copyright` fields to `DownloadResponse`
|
||||
|
||||
- **Flutter Changes**:
|
||||
- `lib/services/cover_cache_manager.dart`: New persistent cache manager for cover images (365 days, 1000 images max)
|
||||
- `lib/widgets/cached_cover_image.dart`: Wrapper widget for CachedNetworkImage with persistent cache
|
||||
@@ -268,7 +740,6 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
|
||||
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
|
||||
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot))
|
||||
|
||||
- **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
|
||||
- Tap the search icon to reveal a dropdown menu with all available search providers
|
||||
- Shows default provider (Deezer based on metadata source setting) at the top
|
||||
@@ -278,56 +749,46 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- Search hint text updates immediately when switching providers
|
||||
- Re-triggers search automatically if there's existing text in the search bar
|
||||
- Eliminates need to navigate to Settings > Extensions > Search Provider
|
||||
|
||||
- **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
|
||||
- Extensions can define `button` type in manifest settings
|
||||
- Triggers JavaScript function when tapped (e.g., start OAuth flow)
|
||||
- Useful for authentication, manual sync, or any custom action
|
||||
|
||||
- **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
|
||||
- Fetches genre and label from Deezer album API for each track
|
||||
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
|
||||
- Works automatically when Deezer track ID is available (via ISRC matching)
|
||||
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads
|
||||
|
||||
- **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
|
||||
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
|
||||
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
|
||||
- Available in both the quality picker dialog and default quality settings
|
||||
- Works with all services (Tidal, Qobuz, Amazon) and extensions
|
||||
|
||||
- **MP3 Metadata Embedding**: Full metadata support for MP3 files
|
||||
- Cover art embedded using ID3v2 tags
|
||||
- Synced lyrics embedded (fetched from lrclib.net)
|
||||
- All metadata preserved: title, artist, album, album artist, track/disc number, date, ISRC
|
||||
- Automatic tag conversion from Vorbis comments (FLAC) to ID3v2 (MP3)
|
||||
|
||||
- **Dominant Color Header**: Album, Playlist, Downloaded Album, and Track Metadata screens now feature dynamic header backgrounds
|
||||
- Extracts dominant color from cover art using `palette_generator`
|
||||
- Creates a gradient from dominant color to theme surface color
|
||||
- Smooth 500ms color transition animation
|
||||
|
||||
- **Larger Cover Art**: Cover images on detail screens are now 50% of screen width (previously 140px fixed)
|
||||
- More prominent album artwork display
|
||||
- Larger shadow and rounded corners (20px radius)
|
||||
- Higher resolution cover caching
|
||||
|
||||
- **Sticky Title**: Title appears in AppBar when scrolling past the info card
|
||||
- Smooth fade-in animation (200ms) when scrolling down
|
||||
- Title hidden when header is expanded (shows in info card instead)
|
||||
- AppBar uses theme color (surface) for clean, native look
|
||||
- Works on Album, Playlist, Downloaded Album, Track Metadata, and Artist screens
|
||||
|
||||
- **Artist Name in Album Screen**: Album info card now displays artist name below album title
|
||||
- Extracted from first track's artist metadata
|
||||
- Styled with `onSurfaceVariant` color for visual hierarchy
|
||||
|
||||
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
|
||||
- Visual disc separator header showing "Disc 1", "Disc 2", etc.
|
||||
- Tracks sorted by disc number first, then by track number
|
||||
- Single-disc albums display normally without separators
|
||||
- Fixes confusion when albums have duplicate track numbers across discs
|
||||
|
||||
- **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
|
||||
- Prevents flooding the recents list when downloading full albums
|
||||
- Groups tracks by album name and artist
|
||||
@@ -340,7 +801,6 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- MP3 files now saved in the same folder as FLAC (no separate MP3 subfolder)
|
||||
- Original FLAC file automatically deleted after successful conversion
|
||||
- New `embedMetadataToMp3()` method for MP3-specific tag embedding
|
||||
|
||||
- **Sticky Header Theme Integration**: AppBar background uses `colorScheme.surface` instead of dominant color when collapsed
|
||||
- Dark theme: Black background with white text
|
||||
- Light theme: White background with black text
|
||||
@@ -352,12 +812,10 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- MP3 files now show "320kbps" instead of FLAC's bit depth/sample rate
|
||||
- History no longer stores FLAC audio specs for converted MP3 files
|
||||
- Both File Info badges and metadata grid show correct MP3 quality
|
||||
|
||||
- **Empty Catch Blocks**: Fixed analyzer warnings for empty catch blocks
|
||||
- `download_queue_provider.dart`: Added comments explaining why polling errors are silently ignored
|
||||
- `track_provider.dart`: Added comments explaining why availability check errors are silently ignored
|
||||
- `ffmpeg_service.dart`: Added proper error logging for temp file cleanup failures
|
||||
|
||||
- **Russian Plural Forms**: Fixed ICU syntax warnings in Russian localization
|
||||
- Removed redundant `=1` clauses that were overriding `one` plural category
|
||||
- Affected 10 plural strings including track counts and delete confirmations
|
||||
@@ -377,24 +835,20 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- Thread-safe cache with automatic expiration
|
||||
- Cache key based on artist, track, and duration
|
||||
- Log indicator shows "(cached)" when lyrics are served from cache
|
||||
|
||||
- **Lyrics Duration Matching**: Improved lyrics accuracy with duration-based matching
|
||||
- Compares track duration with lrclib.net results
|
||||
- 10-second tolerance to handle version differences (radio edit, remaster, etc.)
|
||||
- Prioritizes synced lyrics over plain text when duration matches
|
||||
- Falls back gracefully if no duration match found
|
||||
|
||||
- **Deezer Cover Art Upgrade**: Cover art from Deezer CDN now automatically upgraded to maximum quality
|
||||
- Detects Deezer CDN URLs (`cdn-images.dzcdn.net`)
|
||||
- Upgrades cover resolution to 1800x1800 (max available)
|
||||
- Works alongside existing cover upgrade
|
||||
|
||||
- **Live Search for Extensions**: Search-as-you-type functionality for extension search
|
||||
- 800ms debounce delay to prevent excessive API calls
|
||||
- Minimum 3 characters required before searching
|
||||
- Concurrency control to prevent race conditions in extension runtime
|
||||
- Queues pending searches if a search is already in progress
|
||||
|
||||
- **Russian Language Support**: Added Russian (Русский) translation - 99% complete
|
||||
- Translated via Crowdin community contributions
|
||||
- Covers all UI elements, settings, and error messages
|
||||
@@ -405,12 +859,10 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- Added per-directory build lock using `sync.Map` and `sync.Mutex`
|
||||
- Double-check locking pattern ensures index is built only once
|
||||
- Significantly improves performance during CSV import with many tracks
|
||||
|
||||
- **Queue Tab Scroll Exception**: Fixed Flutter rendering exception with NestedScrollView
|
||||
- Disabled Material 3 stretch overscroll indicator that caused `_StretchController` assertion
|
||||
- Wrapped NestedScrollView with ScrollConfiguration to prevent `setState during build` errors
|
||||
- Issue was especially noticeable during rapid queue updates (CSV import)
|
||||
|
||||
- **CSV Import**: Fixed CSV export not being parsed correctly
|
||||
- Added support for `Artist Name(s)` header (with parentheses)
|
||||
- Added support for `Track URI` header for track IDs
|
||||
@@ -477,4 +929,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
||||
|
||||
---
|
||||
|
||||
*For older versions, see [GitHub Releases](https://github.com/zarzet/SpotiFLAC-Mobile/releases)*
|
||||
_For older versions, see [GitHub Releases_](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3)
|
||||
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
<div align="center">
|
||||
@@ -24,15 +24,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
## Search Source
|
||||
|
||||
SpotiFLAC supports multiple search sources for finding music metadata:
|
||||
|
||||
| Source | Setup |
|
||||
|--------|-------|
|
||||
| **Deezer** (Default) | No setup required |
|
||||
| **Extensions** | Install additional search providers from the Store |
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||
@@ -54,15 +45,8 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
|
||||
|
||||
## Telegram
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/spotiflac">
|
||||
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
|
||||
</a>
|
||||
|
||||
<a href="https://t.me/spotiflac_chat">
|
||||
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
|
||||
</a>
|
||||
</p>
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||
## FAQ
|
||||
|
||||
@@ -87,9 +71,9 @@ A: Some countries have restricted access to certain streaming service APIs. If d
|
||||
|
||||
### Want to support SpotiFLAC-Mobile?
|
||||
|
||||
_If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going._
|
||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
||||
|
||||
[](https://ko-fi.com/zarzet) <a href="https://www.buymeacoffee.com/zarzet" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 40px !important;width: 150px !important;" ></a>
|
||||
[](https://ko-fi.com/zarzet)
|
||||
|
||||
|
||||
## Disclaimer
|
||||
@@ -108,6 +92,16 @@ You are solely responsible for:
|
||||
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
||||
|
||||
|
||||
## API Credits
|
||||
|
||||
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net)
|
||||
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
|
||||
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
|
||||
- **Lyrics**: [LRCLib](https://lrclib.net)
|
||||
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
|
||||
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
|
||||
@@ -96,11 +96,13 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
|
||||
// Include all AAR and JAR files from libs folder
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.12.3")
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
android:label="SpotiFLAC"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
android:usesCleartextTraffic="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:localeConfig="@xml/locale_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -43,7 +43,7 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle Spotify URL sharing -->
|
||||
<!-- Handle music URL sharing (Spotify, Deezer, Tidal, YT Music) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -57,6 +57,33 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="open.spotify.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle Deezer deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="www.deezer.com" />
|
||||
<data android:scheme="https" android:host="deezer.com" />
|
||||
<data android:scheme="https" android:host="deezer.page.link" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle Tidal deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="tidal.com" />
|
||||
<data android:scheme="https" android:host="listen.tidal.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle YouTube Music deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="music.youtube.com" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="ru" />
|
||||
<locale android:name="es-ES" />
|
||||
<locale android:name="id" />
|
||||
<locale android:name="pt-PT" />
|
||||
<locale android:name="ja" />
|
||||
<locale android:name="tr" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="hi" />
|
||||
<locale android:name="ko" />
|
||||
<locale android:name="nl" />
|
||||
<locale android:name="zh" />
|
||||
</locale-config>
|
||||
@@ -22,7 +22,7 @@ subprojects {
|
||||
}
|
||||
|
||||
// Add desugaring dependency to all Android subprojects
|
||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
+370
-131
@@ -17,6 +17,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Amazon API timeout and retry configuration for mobile networks
|
||||
const (
|
||||
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
|
||||
amazonMaxRetries = 2 // Number of retry attempts
|
||||
amazonRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
}
|
||||
@@ -24,6 +31,8 @@ type AmazonDownloader struct {
|
||||
var (
|
||||
globalAmazonDownloader *AmazonDownloader
|
||||
amazonDownloaderOnce sync.Once
|
||||
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
|
||||
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
|
||||
)
|
||||
|
||||
// AfkarXYZResponse is the response from AfkarXYZ API
|
||||
@@ -36,13 +45,10 @@ type AfkarXYZResponse struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func amazonIsASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
// 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 {
|
||||
@@ -54,40 +60,195 @@ func NewAmazonDownloader() *AmazonDownloader {
|
||||
return globalAmazonDownloader
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
|
||||
// Returns downloadURL, suggested fileName, optional decryptionKey.
|
||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
|
||||
if err == nil {
|
||||
return downloadURL, fileName, decryptionKey, nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
lastErr = err
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Check if error is retryable
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "eof") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429") ||
|
||||
strings.Contains(errStr, "http 429")
|
||||
|
||||
if !isRetryable {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func normalizeAmazonASIN(candidate string) string {
|
||||
trimmed := strings.TrimSpace(candidate)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if decoded, err := url.QueryUnescape(trimmed); err == nil {
|
||||
trimmed = decoded
|
||||
}
|
||||
|
||||
trimmed = strings.ToUpper(trimmed)
|
||||
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
|
||||
if amazonASINRegex.MatchString(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractAmazonASIN(amazonURL string) string {
|
||||
raw := strings.TrimSpace(amazonURL)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err == nil {
|
||||
query := parsed.Query()
|
||||
|
||||
// Prefer track-level ASIN when URL also contains albumAsin.
|
||||
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
|
||||
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
|
||||
return asin
|
||||
}
|
||||
}
|
||||
|
||||
path := strings.Trim(parsed.Path, "/")
|
||||
if path != "" {
|
||||
segments := strings.Split(path, "/")
|
||||
|
||||
for i := 0; i < len(segments)-1; i++ {
|
||||
segment := strings.ToLower(strings.TrimSpace(segments[i]))
|
||||
if segment == "track" || segment == "tracks" {
|
||||
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
|
||||
return asin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
|
||||
return asin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
|
||||
return normalizeAmazonASIN(match)
|
||||
}
|
||||
|
||||
// doAfkarXYZRequest performs a single request to Amazon API.
|
||||
// It tries new endpoint first, then falls back to legacy /convert endpoint.
|
||||
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
|
||||
asin := extractAmazonASIN(amazonURL)
|
||||
if asin != "" {
|
||||
GoLog("[Amazon] Using ASIN: %s\n", asin)
|
||||
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
|
||||
if err == nil {
|
||||
return downloadURL, fileName, decryptKey, nil
|
||||
}
|
||||
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
|
||||
}
|
||||
return a.doAfkarXYZRequestLegacy(amazonURL)
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||
defer cancel()
|
||||
|
||||
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
||||
return "", "", "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var apiResp AmazonStreamResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(apiResp.StreamURL) == "" {
|
||||
return "", "", "", fmt.Errorf("Amazon API returned empty stream URL")
|
||||
}
|
||||
|
||||
fileName := asin + ".m4a"
|
||||
return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||
defer cancel()
|
||||
|
||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
|
||||
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
|
||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read response: %w", err)
|
||||
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
|
||||
}
|
||||
|
||||
var apiResp AfkarXYZResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return "", "", fmt.Errorf("failed to decode response: %w", err)
|
||||
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
|
||||
}
|
||||
|
||||
if !apiResp.Success || apiResp.Data.DirectLink == "" {
|
||||
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
|
||||
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
|
||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
|
||||
}
|
||||
|
||||
fileName := apiResp.Data.FileName
|
||||
@@ -98,12 +259,25 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin
|
||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||
fileName = reg.ReplaceAllString(fileName, "")
|
||||
|
||||
GoLog("[Amazon] AfkarXYZ returned: %s (%.2f MB)\n", fileName, float64(apiResp.Data.FileSize)/(1024*1024))
|
||||
|
||||
return apiResp.Data.DirectLink, fileName, nil
|
||||
return apiResp.Data.DirectLink, fileName, "", nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
|
||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||
|
||||
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if decryptionKey != "" {
|
||||
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
|
||||
}
|
||||
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
||||
return downloadURL, fileName, decryptionKey, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if itemID != "" {
|
||||
@@ -142,7 +316,7 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -161,23 +335,23 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
@@ -187,54 +361,74 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
|
||||
// AmazonDownloadResult contains download result with quality info
|
||||
type AmazonDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
DecryptionKey string
|
||||
}
|
||||
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
amazonURL := ""
|
||||
if req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
|
||||
amazonURL = cached.AmazonURL
|
||||
GoLog("[Amazon] Cache hit! Using cached Amazon URL for ISRC %s\n", req.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
songlink := NewSongLinkClient()
|
||||
var availability *TrackAvailability
|
||||
var err error
|
||||
|
||||
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
|
||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
} else {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||
if amazonURL == "" {
|
||||
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
|
||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
} else {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
amazonURL = availability.AmazonURL
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
if req.OutputDir != "." {
|
||||
if !isSafOutput && req.OutputDir != "." {
|
||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Download using AfkarXYZ API
|
||||
downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL)
|
||||
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
||||
}
|
||||
@@ -249,11 +443,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
|
||||
if outputExt == "" {
|
||||
outputExt = ".flac"
|
||||
}
|
||||
filename = sanitizeFilename(filename) + outputExt
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||
@@ -273,13 +478,19 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}()
|
||||
|
||||
// Download audio file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return AmazonDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
actualOutputPath := outputPath
|
||||
needsDecryption := strings.TrimSpace(decryptionKey) != ""
|
||||
if needsDecryption {
|
||||
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
|
||||
}
|
||||
|
||||
// Wait for parallel operations to complete
|
||||
<-parallelDone
|
||||
|
||||
@@ -288,7 +499,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||
actualTrackNum := req.TrackNumber
|
||||
actualDiscNum := req.DiscNumber
|
||||
actualDate := req.ReleaseDate
|
||||
@@ -296,25 +506,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
actualTitle := req.TrackName
|
||||
actualArtist := req.ArtistName
|
||||
|
||||
if metaErr == nil && existingMeta != nil {
|
||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||
actualTrackNum = existingMeta.TrackNumber
|
||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||
if !needsDecryption {
|
||||
existingMeta, metaErr := ReadMetadata(actualOutputPath)
|
||||
if metaErr == nil && existingMeta != nil {
|
||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||
actualTrackNum = existingMeta.TrackNumber
|
||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||
}
|
||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||
actualDiscNum = existingMeta.DiscNumber
|
||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||
}
|
||||
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
||||
actualDate = existingMeta.Date
|
||||
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
||||
}
|
||||
if existingMeta.Album != "" && req.AlbumName == "" {
|
||||
actualAlbum = existingMeta.Album
|
||||
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
||||
}
|
||||
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
||||
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
||||
}
|
||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||
actualDiscNum = existingMeta.DiscNumber
|
||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||
}
|
||||
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
||||
actualDate = existingMeta.Date
|
||||
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
||||
}
|
||||
if existingMeta.Album != "" && req.AlbumName == "" {
|
||||
actualAlbum = existingMeta.Album
|
||||
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
||||
}
|
||||
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
||||
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
@@ -337,7 +550,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
coverData = parallelResult.CoverData
|
||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
} else {
|
||||
existingCover, coverErr := ExtractCoverArt(outputPath)
|
||||
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
|
||||
if coverErr == nil && len(existingCover) > 0 {
|
||||
coverData = existingCover
|
||||
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
||||
@@ -346,59 +559,78 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||
if isSafOutput || needsDecryption {
|
||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
} else {
|
||||
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
|
||||
if isFlacOutput {
|
||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||
}
|
||||
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
|
||||
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||
|
||||
quality, err := GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
quality := AudioQuality{}
|
||||
if isSafOutput || needsDecryption {
|
||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
||||
} else {
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
quality, err = GetAudioQuality(actualOutputPath)
|
||||
if err != nil {
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
} else {
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
|
||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||
if metaReadErr == nil && finalMeta != nil {
|
||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||
actualTrackNum = finalMeta.TrackNumber
|
||||
actualDiscNum = finalMeta.DiscNumber
|
||||
if finalMeta.Date != "" {
|
||||
req.ReleaseDate = finalMeta.Date
|
||||
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
|
||||
if metaReadErr == nil && finalMeta != nil {
|
||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||
actualTrackNum = finalMeta.TrackNumber
|
||||
actualDiscNum = finalMeta.DiscNumber
|
||||
if finalMeta.Date != "" {
|
||||
req.ReleaseDate = finalMeta.Date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to ISRC index for fast duplicate checking
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
// Add to ISRC index for fast duplicate checking.
|
||||
// When decryption is pending in Flutter, postpone indexing until final file is settled.
|
||||
if !isSafOutput && !needsDecryption {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
}
|
||||
|
||||
bitDepth := 0
|
||||
sampleRate := 0
|
||||
@@ -407,16 +639,23 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: actualTrackNum,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: actualTrackNum,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
DecryptionKey: decryptionKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractAmazonASIN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prefers trackAsin over albumAsin",
|
||||
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
|
||||
want: "B0TRACK456",
|
||||
},
|
||||
{
|
||||
name: "extract from tracks path",
|
||||
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
|
||||
want: "B0CYQHGWZJ",
|
||||
},
|
||||
{
|
||||
name: "extract from plain query asin",
|
||||
url: "https://example.com/?asin=B0CYQHGWZJ",
|
||||
want: "B0CYQHGWZJ",
|
||||
},
|
||||
{
|
||||
name: "fallback regex",
|
||||
url: "https://example.com/path/B0CYQHGWZJ",
|
||||
want: "B0CYQHGWZJ",
|
||||
},
|
||||
{
|
||||
name: "invalid url",
|
||||
url: "https://music.amazon.com/tracks/not-valid",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractAmazonASIN(tt.url)
|
||||
if got != tt.want {
|
||||
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+170
-15
@@ -23,15 +23,28 @@ const (
|
||||
deezerCacheTTL = 10 * time.Minute
|
||||
|
||||
deezerMaxParallelISRC = 10
|
||||
|
||||
// Deezer API timeout and retry configuration for mobile networks
|
||||
deezerAPITimeoutMobile = 25 * time.Second
|
||||
deezerMaxRetries = 2
|
||||
deezerRetryDelay = 500 * time.Millisecond
|
||||
|
||||
deezerMaxSearchCacheEntries = 300
|
||||
deezerMaxAlbumCacheEntries = 200
|
||||
deezerMaxArtistCacheEntries = 200
|
||||
deezerMaxISRCCacheEntries = 4000
|
||||
deezerCacheCleanupInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
type DeezerClient struct {
|
||||
httpClient *http.Client
|
||||
searchCache map[string]*cacheEntry
|
||||
albumCache map[string]*cacheEntry
|
||||
artistCache map[string]*cacheEntry
|
||||
isrcCache map[string]string
|
||||
cacheMu sync.RWMutex
|
||||
httpClient *http.Client
|
||||
searchCache map[string]*cacheEntry
|
||||
albumCache map[string]*cacheEntry
|
||||
artistCache map[string]*cacheEntry
|
||||
isrcCache map[string]string
|
||||
cacheMu sync.RWMutex
|
||||
lastCacheCleanup time.Time
|
||||
cacheCleanupInterval time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -42,16 +55,111 @@ var (
|
||||
func GetDeezerClient() *DeezerClient {
|
||||
deezerClientOnce.Do(func() {
|
||||
deezerClient = &DeezerClient{
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
searchCache: make(map[string]*cacheEntry),
|
||||
albumCache: make(map[string]*cacheEntry),
|
||||
artistCache: make(map[string]*cacheEntry),
|
||||
isrcCache: make(map[string]string),
|
||||
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
|
||||
searchCache: make(map[string]*cacheEntry),
|
||||
albumCache: make(map[string]*cacheEntry),
|
||||
artistCache: make(map[string]*cacheEntry),
|
||||
isrcCache: make(map[string]string),
|
||||
cacheCleanupInterval: deezerCacheCleanupInterval,
|
||||
}
|
||||
})
|
||||
return deezerClient
|
||||
}
|
||||
|
||||
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
|
||||
cache map[string]*cacheEntry,
|
||||
now time.Time,
|
||||
) {
|
||||
for key, entry := range cache {
|
||||
if entry == nil || now.After(entry.expiresAt) {
|
||||
delete(cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DeezerClient) trimCacheEntriesLocked(
|
||||
cache map[string]*cacheEntry,
|
||||
maxEntries int,
|
||||
) {
|
||||
if maxEntries <= 0 || len(cache) <= maxEntries {
|
||||
return
|
||||
}
|
||||
|
||||
for len(cache) > maxEntries {
|
||||
var oldestKey string
|
||||
var oldestExpiry time.Time
|
||||
first := true
|
||||
for key, entry := range cache {
|
||||
expiry := time.Time{}
|
||||
if entry != nil {
|
||||
expiry = entry.expiresAt
|
||||
}
|
||||
if first || expiry.Before(oldestExpiry) {
|
||||
first = false
|
||||
oldestKey = key
|
||||
oldestExpiry = expiry
|
||||
}
|
||||
}
|
||||
if oldestKey == "" {
|
||||
return
|
||||
}
|
||||
delete(cache, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DeezerClient) trimStringCacheEntriesLocked(
|
||||
cache map[string]string,
|
||||
maxEntries int,
|
||||
) {
|
||||
if maxEntries <= 0 || len(cache) <= maxEntries {
|
||||
return
|
||||
}
|
||||
|
||||
toRemove := len(cache) - maxEntries
|
||||
for key := range cache {
|
||||
delete(cache, key)
|
||||
toRemove--
|
||||
if toRemove <= 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
|
||||
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
|
||||
(c.lastCacheCleanup.IsZero() ||
|
||||
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
|
||||
|
||||
if periodicCleanupDue {
|
||||
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
||||
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
||||
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
||||
c.lastCacheCleanup = now
|
||||
}
|
||||
|
||||
if len(c.searchCache) > deezerMaxSearchCacheEntries {
|
||||
if !periodicCleanupDue {
|
||||
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
||||
}
|
||||
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
|
||||
}
|
||||
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
|
||||
if !periodicCleanupDue {
|
||||
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
||||
}
|
||||
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
|
||||
}
|
||||
if len(c.artistCache) > deezerMaxArtistCacheEntries {
|
||||
if !periodicCleanupDue {
|
||||
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
||||
}
|
||||
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
|
||||
}
|
||||
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
|
||||
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
|
||||
}
|
||||
}
|
||||
|
||||
type deezerTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -409,10 +517,12 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
|
||||
|
||||
c.cacheMu.Lock()
|
||||
now := time.Now()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||
expiresAt: now.Add(deezerCacheTTL),
|
||||
}
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
@@ -550,10 +660,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
now := time.Now()
|
||||
c.albumCache[albumID] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||
expiresAt: now.Add(deezerCacheTTL),
|
||||
}
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
@@ -633,10 +745,12 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
now := time.Now()
|
||||
c.artistCache[artistID] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||
expiresAt: now.Add(deezerCacheTTL),
|
||||
}
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
@@ -802,6 +916,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
for trackIDStr, isrc := range directISRCs {
|
||||
c.isrcCache[trackIDStr] = isrc
|
||||
}
|
||||
c.maybeCleanupCachesLocked(time.Now())
|
||||
c.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -836,6 +951,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
||||
c.maybeCleanupCachesLocked(time.Now())
|
||||
c.cacheMu.Unlock()
|
||||
}(track)
|
||||
}
|
||||
@@ -859,6 +975,7 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.isrcCache[trackID] = fullTrack.ISRC
|
||||
c.maybeCleanupCachesLocked(time.Now())
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return fullTrack.ISRC, nil
|
||||
@@ -941,10 +1058,12 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
now := time.Now()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||
expiresAt: now.Add(deezerCacheTTL),
|
||||
}
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||
@@ -992,6 +1111,42 @@ func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc strin
|
||||
}
|
||||
|
||||
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
err := c.doGetJSON(ctx, endpoint, dst)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
errStr := err.Error()
|
||||
|
||||
// Check if error is retryable
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429")
|
||||
|
||||
if !isRetryable {
|
||||
return err
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+1073
-142
File diff suppressed because it is too large
Load Diff
@@ -47,7 +47,7 @@ type LoadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
@@ -58,8 +58,8 @@ type LoadedExtension struct {
|
||||
type ExtensionManager struct {
|
||||
mu sync.RWMutex
|
||||
extensions map[string]*LoadedExtension
|
||||
extensionsDir string // Base directory for extensions
|
||||
dataDir string // Base directory for extension data
|
||||
extensionsDir string
|
||||
dataDir string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -98,7 +98,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
// Open the zip file
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||
@@ -221,7 +220,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
// Initialize Goja VM
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
@@ -268,13 +266,11 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// Run the extension code
|
||||
_, err = vm.RunString(string(jsCode))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||
}
|
||||
|
||||
// Verify extension was registered
|
||||
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
||||
return fmt.Errorf("extension did not call registerExtension()")
|
||||
}
|
||||
@@ -291,9 +287,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
// Call cleanup if VM is initialized
|
||||
if ext.VM != nil {
|
||||
// Try to call cleanup function
|
||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
||||
if err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
||||
@@ -302,7 +296,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from registry
|
||||
delete(m.extensions, extensionID)
|
||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||
|
||||
@@ -343,7 +336,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
ext.Enabled = enabled
|
||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||
|
||||
// Persist enabled state to settings store
|
||||
store := GetExtensionSettingsStore()
|
||||
if err := store.Set(extensionID, "_enabled", enabled); err != nil {
|
||||
GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err)
|
||||
@@ -438,7 +430,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Goja VM
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
@@ -457,12 +448,10 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unload first
|
||||
if err := m.UnloadExtension(extensionID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove source directory
|
||||
if ext.SourceDir != "" {
|
||||
if err := os.RemoveAll(ext.SourceDir); err != nil {
|
||||
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
|
||||
@@ -484,7 +473,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
// Open the zip file
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||
@@ -548,11 +536,9 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
extDir := existing.SourceDir
|
||||
wasEnabled := existing.Enabled
|
||||
|
||||
// Cleanup and unload existing extension
|
||||
m.CleanupExtension(existing.ID)
|
||||
m.UnloadExtension(existing.ID)
|
||||
|
||||
// Remove old source files but keep data directory
|
||||
if extDir != "" {
|
||||
if err := os.RemoveAll(extDir); err != nil {
|
||||
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
|
||||
@@ -634,11 +620,11 @@ type ExtensionUpgradeInfo struct {
|
||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
// Validate file extension
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format")
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
// Open the zip file
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file")
|
||||
}
|
||||
|
||||
@@ -175,7 +175,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate settings if present
|
||||
for i, setting := range m.Settings {
|
||||
if strings.TrimSpace(setting.Key) == "" {
|
||||
return &ManifestValidationError{
|
||||
@@ -236,7 +235,7 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
}
|
||||
// Support wildcard subdomains (e.g., *.example.com)
|
||||
if strings.HasPrefix(allowed, "*.") {
|
||||
suffix := allowed[1:] // Remove the *
|
||||
suffix := allowed[1:]
|
||||
if strings.HasSuffix(domain, suffix) {
|
||||
return true
|
||||
}
|
||||
@@ -269,7 +268,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||
for _, pattern := range m.URLHandler.Patterns {
|
||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||
// Check if URL contains the pattern (host match)
|
||||
if strings.Contains(urlStr, pattern) {
|
||||
return true
|
||||
}
|
||||
|
||||
+207
-175
@@ -25,27 +25,26 @@ type ExtTrackMetadata struct {
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Images string `json:"images,omitempty"` // Alternative field for cover URL (used by some extensions)
|
||||
Images string `json:"images,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
// Enrichment fields from Odesli/song.link
|
||||
ItemType string `json:"item_type,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
|
||||
// Extended metadata from enrichment (can come from Deezer, Spotify, etc.)
|
||||
Label string `json:"label,omitempty"` // Record label
|
||||
Copyright string `json:"copyright,omitempty"` // Copyright information
|
||||
Genre string `json:"genre,omitempty"` // Music genre(s)
|
||||
ExternalLinks map[string]string `json:"external_links,omitempty"`
|
||||
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
}
|
||||
|
||||
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
|
||||
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
||||
if t.CoverURL != "" {
|
||||
return t.CoverURL
|
||||
@@ -53,7 +52,6 @@ func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
||||
return t.Images
|
||||
}
|
||||
|
||||
// ExtAlbumMetadata represents album metadata from an extension
|
||||
type ExtAlbumMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -67,34 +65,28 @@ type ExtAlbumMetadata struct {
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
|
||||
// ExtArtistMetadata represents artist metadata from an extension
|
||||
type ExtArtistMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background
|
||||
Listeners int `json:"listeners,omitempty"` // Monthly listeners
|
||||
HeaderImage string `json:"header_image,omitempty"`
|
||||
Listeners int `json:"listeners,omitempty"`
|
||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` // Popular tracks
|
||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
|
||||
// ExtSearchResult represents search results from an extension
|
||||
type ExtSearchResult struct {
|
||||
Tracks []ExtTrackMetadata `json:"tracks"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ==================== Download Types ====================
|
||||
|
||||
// ExtAvailabilityResult represents availability check result
|
||||
type ExtAvailabilityResult struct {
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
TrackID string `json:"track_id,omitempty"`
|
||||
}
|
||||
|
||||
// ExtDownloadURLResult represents download URL info
|
||||
type ExtDownloadURLResult struct {
|
||||
URL string `json:"url"`
|
||||
Format string `json:"format"`
|
||||
@@ -102,7 +94,6 @@ type ExtDownloadURLResult struct {
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
}
|
||||
|
||||
// ExtDownloadResult represents download result from an extension
|
||||
type ExtDownloadResult struct {
|
||||
Success bool `json:"success"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
@@ -110,7 +101,7 @@ type ExtDownloadResult struct {
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
// Metadata returned by extension (optional - if provided, can skip enrichment)
|
||||
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
@@ -122,15 +113,11 @@ type ExtDownloadResult struct {
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== Provider Wrapper ====================
|
||||
|
||||
// ExtensionProviderWrapper wraps an extension to call its provider methods
|
||||
type ExtensionProviderWrapper struct {
|
||||
extension *LoadedExtension
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
// NewExtensionProviderWrapper creates a new provider wrapper
|
||||
func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper {
|
||||
return &ExtensionProviderWrapper{
|
||||
extension: ext,
|
||||
@@ -138,9 +125,6 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Metadata Provider Methods ====================
|
||||
|
||||
// SearchTracks searches for tracks using the extension
|
||||
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -150,11 +134,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Call extension's searchTracks function
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.searchTracks === 'function') {
|
||||
@@ -176,7 +158,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
return nil, fmt.Errorf("searchTracks returned null")
|
||||
}
|
||||
|
||||
// Convert result to Go struct
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
@@ -185,14 +166,11 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
|
||||
var searchResult ExtSearchResult
|
||||
|
||||
// Try to parse as ExtSearchResult object first
|
||||
if err := json.Unmarshal(jsonBytes, &searchResult); err != nil {
|
||||
// If that fails, try parsing as array of tracks directly
|
||||
var tracks []ExtTrackMetadata
|
||||
if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil {
|
||||
return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr)
|
||||
}
|
||||
// Wrap array in ExtSearchResult
|
||||
searchResult = ExtSearchResult{
|
||||
Tracks: tracks,
|
||||
Total: len(tracks),
|
||||
@@ -206,7 +184,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
return &searchResult, nil
|
||||
}
|
||||
|
||||
// GetTrack gets track details by ID
|
||||
func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -216,7 +193,6 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -256,7 +232,6 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
// GetAlbum gets album details by ID
|
||||
func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -266,7 +241,6 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -309,7 +283,6 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
// GetArtist gets artist details by ID
|
||||
func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -319,7 +292,6 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -359,27 +331,22 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
// EnrichTrack enriches track metadata before download (e.g., fetch real ISRC)
|
||||
// This is called lazily when download starts, not when playlist/album is loaded
|
||||
// Extension should implement enrichTrack(track) function that returns enriched track
|
||||
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return track, nil // Not a metadata provider, return as-is
|
||||
return track, nil
|
||||
}
|
||||
|
||||
if !p.extension.Enabled {
|
||||
return track, nil // Extension disabled, return as-is
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Convert track to JSON for passing to JS
|
||||
trackJSON, err := json.Marshal(track)
|
||||
if err != nil {
|
||||
GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err)
|
||||
return track, nil // Return original on error
|
||||
return track, nil
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -399,10 +366,9 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
} else {
|
||||
GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err)
|
||||
}
|
||||
return track, nil // Return original on error
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// If extension doesn't implement enrichTrack or returns null, return original
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return track, nil
|
||||
}
|
||||
@@ -420,18 +386,11 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// Preserve provider ID
|
||||
enrichedTrack.ProviderID = track.ProviderID
|
||||
|
||||
GoLog("[Extension] EnrichTrack: enriched track from %s (ISRC: %s -> %s)\n",
|
||||
p.extension.ID, track.ISRC, enrichedTrack.ISRC)
|
||||
|
||||
return &enrichedTrack, nil
|
||||
}
|
||||
|
||||
// ==================== Download Provider Methods ====================
|
||||
|
||||
// CheckAvailability checks if a track is available for download
|
||||
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
@@ -441,7 +400,6 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -480,7 +438,6 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
||||
return &availability, nil
|
||||
}
|
||||
|
||||
// GetDownloadURL gets the download URL for a track
|
||||
func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
@@ -490,7 +447,6 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -529,10 +485,8 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
||||
return &urlResult, nil
|
||||
}
|
||||
|
||||
// ExtDownloadTimeout is longer for extension download operations (5 minutes)
|
||||
const ExtDownloadTimeout = 5 * time.Minute
|
||||
|
||||
// Download downloads a track with progress reporting
|
||||
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
@@ -542,15 +496,12 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Set up progress callback in VM
|
||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
percent := int(call.Arguments[0].ToInteger())
|
||||
// Clamp to 0-100
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
@@ -573,7 +524,6 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
||||
})()
|
||||
`, trackID, quality, outputPath)
|
||||
|
||||
// Use longer timeout for downloads (5 minutes)
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
@@ -619,9 +569,6 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
||||
return &downloadResult, nil
|
||||
}
|
||||
|
||||
// ==================== Extension Manager Provider Methods ====================
|
||||
|
||||
// GetMetadataProviders returns all enabled metadata provider extensions
|
||||
func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -635,7 +582,6 @@ func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
|
||||
return providers
|
||||
}
|
||||
|
||||
// GetDownloadProviders returns all enabled download provider extensions
|
||||
func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -649,7 +595,6 @@ func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
|
||||
return providers
|
||||
}
|
||||
|
||||
// SearchTracksWithExtensions searches all metadata providers
|
||||
func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
providers := m.GetMetadataProviders()
|
||||
if len(providers) == 0 {
|
||||
@@ -671,18 +616,12 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
|
||||
return allTracks, nil
|
||||
}
|
||||
|
||||
// ==================== Provider Priority ====================
|
||||
|
||||
// providerPriority stores the order of download providers
|
||||
var providerPriority []string
|
||||
var providerPriorityMu sync.RWMutex
|
||||
|
||||
// metadataProviderPriority stores the order of metadata providers
|
||||
var metadataProviderPriority []string
|
||||
var metadataProviderPriorityMu sync.RWMutex
|
||||
|
||||
// SetProviderPriority sets the order of download providers
|
||||
// providerIDs should include both built-in ("tidal", "qobuz", "amazon") and extension IDs
|
||||
func SetProviderPriority(providerIDs []string) {
|
||||
providerPriorityMu.Lock()
|
||||
defer providerPriorityMu.Unlock()
|
||||
@@ -690,13 +629,11 @@ func SetProviderPriority(providerIDs []string) {
|
||||
GoLog("[Extension] Download provider priority set: %v\n", providerIDs)
|
||||
}
|
||||
|
||||
// GetProviderPriority returns the current provider priority order
|
||||
func GetProviderPriority() []string {
|
||||
providerPriorityMu.RLock()
|
||||
defer providerPriorityMu.RUnlock()
|
||||
|
||||
if len(providerPriority) == 0 {
|
||||
// Default order: built-in providers first
|
||||
return []string{"tidal", "qobuz", "amazon"}
|
||||
}
|
||||
|
||||
@@ -705,8 +642,6 @@ func GetProviderPriority() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// SetMetadataProviderPriority sets the order of metadata providers
|
||||
// providerIDs should include both built-in ("spotify", "deezer") and extension IDs
|
||||
func SetMetadataProviderPriority(providerIDs []string) {
|
||||
metadataProviderPriorityMu.Lock()
|
||||
defer metadataProviderPriorityMu.Unlock()
|
||||
@@ -714,13 +649,11 @@ func SetMetadataProviderPriority(providerIDs []string) {
|
||||
GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
|
||||
}
|
||||
|
||||
// GetMetadataProviderPriority returns the current metadata provider priority order
|
||||
func GetMetadataProviderPriority() []string {
|
||||
metadataProviderPriorityMu.RLock()
|
||||
defer metadataProviderPriorityMu.RUnlock()
|
||||
|
||||
if len(metadataProviderPriority) == 0 {
|
||||
// Default order: built-in providers first
|
||||
return []string{"deezer", "spotify"}
|
||||
}
|
||||
|
||||
@@ -729,7 +662,6 @@ func GetMetadataProviderPriority() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// isBuiltInProvider checks if a provider ID is a built-in provider
|
||||
func isBuiltInProvider(providerID string) bool {
|
||||
switch providerID {
|
||||
case "tidal", "qobuz", "amazon", "deezer":
|
||||
@@ -739,20 +671,12 @@ func isBuiltInProvider(providerID string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Download with Fallback ====================
|
||||
|
||||
// DownloadWithExtensionFallback tries to download from providers in priority order
|
||||
// Includes both built-in providers and extension providers
|
||||
// If req.Source is set (extension ID), that extension is tried first
|
||||
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||
priority := GetProviderPriority()
|
||||
extManager := GetExtensionManager()
|
||||
|
||||
// If req.Service is a built-in provider, prioritize it first
|
||||
// This handles user's explicit selection from the service picker
|
||||
if req.Service != "" && isBuiltInProvider(req.Service) {
|
||||
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
|
||||
// Reorder priority to put req.Service first
|
||||
newPriority := []string{req.Service}
|
||||
for _, p := range priority {
|
||||
if p != req.Service {
|
||||
@@ -764,10 +688,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
|
||||
var skipBuiltIn bool
|
||||
|
||||
// LAZY ENRICHMENT: If track came from an extension, try to enrich metadata (e.g., get real ISRC)
|
||||
// This is done lazily at download time, not when playlist/album is loaded
|
||||
if req.Source != "" && !isBuiltInProvider(req.Source) {
|
||||
ext, err := extManager.GetExtension(req.Source)
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
|
||||
@@ -811,7 +733,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if enrichedTrack.Artists != "" {
|
||||
req.ArtistName = enrichedTrack.Artists
|
||||
}
|
||||
// Copy extended metadata from enrichment (label, copyright, genre, release_date)
|
||||
if enrichedTrack.Label != "" && req.Label == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
||||
req.Label = enrichedTrack.Label
|
||||
@@ -832,7 +753,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// If source extension is specified, try it first before the priority list
|
||||
if req.Source != "" && !isBuiltInProvider(req.Source) {
|
||||
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
|
||||
|
||||
@@ -842,15 +762,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
|
||||
// For tracks from extension search, use the track ID directly (e.g., "youtube:VIDEO_ID")
|
||||
// The extension already knows how to handle this ID
|
||||
trackID := req.SpotifyID // This contains the extension's track ID (e.g., "youtube:xxx")
|
||||
trackID := req.SpotifyID
|
||||
|
||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
||||
|
||||
outputPath := buildOutputPath(req)
|
||||
|
||||
// Download directly using the track ID from the extension
|
||||
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
||||
@@ -870,7 +787,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
// Embed genre and label if provided (from Deezer metadata)
|
||||
if req.Genre != "" || req.Label != "" {
|
||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||
@@ -879,7 +795,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// If extension has skipMetadataEnrichment, copy metadata
|
||||
if ext.Manifest.SkipMetadataEnrichment {
|
||||
resp.SkipMetadataEnrichment = true
|
||||
if result.Title != "" {
|
||||
@@ -929,12 +844,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
|
||||
|
||||
// If skipBuiltInFallback is true, don't continue to other providers
|
||||
if skipBuiltIn {
|
||||
GoLog("[DownloadWithExtensionFallback] skipBuiltInFallback is true, not trying other providers\n")
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Download failed: %v", lastErr),
|
||||
Error: "Download failed: " + lastErr.Error(),
|
||||
ErrorType: "extension_error",
|
||||
Service: req.Source,
|
||||
}, nil
|
||||
@@ -944,14 +858,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with priority list
|
||||
for _, providerID := range priority {
|
||||
// Skip if we already tried this as source
|
||||
if providerID == req.Source {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip built-in providers if skipBuiltIn is set
|
||||
if skipBuiltIn && isBuiltInProvider(providerID) {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
|
||||
continue
|
||||
@@ -960,7 +871,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||
|
||||
if isBuiltInProvider(providerID) {
|
||||
// For built-in providers, enrich with Deezer metadata if not already present
|
||||
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -981,11 +891,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// Use built-in provider
|
||||
result, err := tryBuiltInProvider(providerID, req)
|
||||
if err == nil && result.Success {
|
||||
result.Service = providerID
|
||||
// Copy enriched metadata to response for Flutter (needed for M4A->FLAC conversion)
|
||||
if req.Label != "" {
|
||||
result.Label = req.Label
|
||||
}
|
||||
@@ -1013,7 +921,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
|
||||
}
|
||||
} else {
|
||||
// Try extension provider
|
||||
ext, err := extManager.GetExtension(providerID)
|
||||
if err != nil || !ext.Enabled || ext.Error != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID)
|
||||
@@ -1056,7 +963,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
// Embed genre and label if provided (from Deezer metadata)
|
||||
if req.Genre != "" || req.Label != "" {
|
||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||
@@ -1065,10 +971,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// If extension has skipMetadataEnrichment and returned metadata, use it
|
||||
if ext.Manifest.SkipMetadataEnrichment {
|
||||
resp.SkipMetadataEnrichment = true
|
||||
// Copy metadata from extension result if provided
|
||||
if result.Title != "" {
|
||||
resp.Title = result.Title
|
||||
}
|
||||
@@ -1121,7 +1025,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if lastErr != nil {
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("All providers failed. Last error: %v", lastErr),
|
||||
Error: "All providers failed. Last error: " + lastErr.Error(),
|
||||
ErrorType: "not_found",
|
||||
}, nil
|
||||
}
|
||||
@@ -1133,7 +1037,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tryBuiltInProvider attempts download from a built-in provider
|
||||
func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) {
|
||||
req.Service = providerID
|
||||
|
||||
@@ -1179,16 +1082,18 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||
if amazonErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: amazonResult.FilePath,
|
||||
BitDepth: amazonResult.BitDepth,
|
||||
SampleRate: amazonResult.SampleRate,
|
||||
Title: amazonResult.Title,
|
||||
Artist: amazonResult.Artist,
|
||||
Album: amazonResult.Album,
|
||||
ReleaseDate: amazonResult.ReleaseDate,
|
||||
TrackNumber: amazonResult.TrackNumber,
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
FilePath: amazonResult.FilePath,
|
||||
BitDepth: amazonResult.BitDepth,
|
||||
SampleRate: amazonResult.SampleRate,
|
||||
Title: amazonResult.Title,
|
||||
Artist: amazonResult.Artist,
|
||||
Album: amazonResult.Album,
|
||||
ReleaseDate: amazonResult.ReleaseDate,
|
||||
TrackNumber: amazonResult.TrackNumber,
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
LyricsLRC: amazonResult.LyricsLRC,
|
||||
DecryptionKey: amazonResult.DecryptionKey,
|
||||
}
|
||||
}
|
||||
err = amazonErr
|
||||
@@ -1216,11 +1121,16 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildOutputPath builds the output file path from request
|
||||
func buildOutputPath(req DownloadRequest) string {
|
||||
if strings.TrimSpace(req.OutputPath) != "" {
|
||||
return strings.TrimSpace(req.OutputPath)
|
||||
}
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
@@ -1236,12 +1146,16 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s.flac", req.OutputDir, filename)
|
||||
ext := strings.TrimSpace(req.OutputExt)
|
||||
if ext == "" {
|
||||
ext = ".flac"
|
||||
} else if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext)
|
||||
}
|
||||
|
||||
// ==================== Custom Search ====================
|
||||
|
||||
// CustomSearch performs a custom search using an extension's search function
|
||||
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.HasCustomSearch() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
|
||||
@@ -1251,21 +1165,33 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Convert options to JSON
|
||||
optionsJSON, _ := json.Marshal(options)
|
||||
if options == nil {
|
||||
options = map[string]interface{}{}
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
// Avoid embedding user input directly into JS source. Some inputs can trigger
|
||||
// parser/runtime edge cases on specific devices/Goja builds.
|
||||
const queryVar = "__sf_custom_search_query"
|
||||
const optionsVar = "__sf_custom_search_options"
|
||||
global := p.vm.GlobalObject()
|
||||
_ = global.Set(queryVar, query)
|
||||
_ = global.Set(optionsVar, options)
|
||||
defer func() {
|
||||
global.Delete(queryVar)
|
||||
global.Delete(optionsVar)
|
||||
}()
|
||||
|
||||
const script = `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
|
||||
return extension.customSearch(%q, %s);
|
||||
return extension.customSearch(__sf_custom_search_query, __sf_custom_search_options);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, query, string(optionsJSON))
|
||||
`
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
@@ -1276,7 +1202,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
// Return empty array instead of error for no results
|
||||
return []ExtTrackMetadata{}, nil
|
||||
}
|
||||
|
||||
@@ -1291,7 +1216,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
return nil, fmt.Errorf("failed to parse search result: %w", err)
|
||||
}
|
||||
|
||||
// Return empty array if no tracks found
|
||||
if tracks == nil {
|
||||
tracks = []ExtTrackMetadata{}
|
||||
}
|
||||
@@ -1303,20 +1227,16 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// ==================== Custom URL Handler ====================
|
||||
|
||||
// ExtURLHandleResult represents the result of URL handling
|
||||
type ExtURLHandleResult struct {
|
||||
Type string `json:"type"` // "track", "album", "playlist", "artist"
|
||||
Track *ExtTrackMetadata `json:"track,omitempty"` // For single track
|
||||
Tracks []ExtTrackMetadata `json:"tracks,omitempty"` // For album/playlist
|
||||
Album *ExtAlbumMetadata `json:"album,omitempty"` // Album info
|
||||
Artist *ExtArtistMetadata `json:"artist,omitempty"` // Artist info
|
||||
Name string `json:"name,omitempty"` // Playlist/album name
|
||||
CoverURL string `json:"cover_url,omitempty"` // Cover image
|
||||
Type string `json:"type"`
|
||||
Track *ExtTrackMetadata `json:"track,omitempty"`
|
||||
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
|
||||
Album *ExtAlbumMetadata `json:"album,omitempty"`
|
||||
Artist *ExtArtistMetadata `json:"artist,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
}
|
||||
|
||||
// HandleURL processes a URL using the extension's URL handler
|
||||
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||
if !p.extension.Manifest.HasURLHandler() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
|
||||
@@ -1326,7 +1246,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -1362,7 +1281,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
return nil, fmt.Errorf("failed to parse URL handle result: %w", err)
|
||||
}
|
||||
|
||||
// Set provider ID on tracks
|
||||
if handleResult.Track != nil {
|
||||
handleResult.Track.ProviderID = p.extension.ID
|
||||
}
|
||||
@@ -1391,9 +1309,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
return &handleResult, nil
|
||||
}
|
||||
|
||||
// ==================== Custom Track Matching ====================
|
||||
|
||||
// MatchTrackResult represents the result of custom track matching
|
||||
type MatchTrackResult struct {
|
||||
Matched bool `json:"matched"`
|
||||
TrackID string `json:"track_id,omitempty"`
|
||||
@@ -1401,7 +1316,6 @@ type MatchTrackResult struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// MatchTrack uses extension's custom matching algorithm
|
||||
func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
|
||||
if !p.extension.Manifest.HasCustomMatching() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
|
||||
@@ -1411,7 +1325,6 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -1453,22 +1366,26 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
||||
return &matchResult, nil
|
||||
}
|
||||
|
||||
// ==================== Post-Processing ====================
|
||||
|
||||
// PostProcessResult represents the result of post-processing
|
||||
type PostProcessResult struct {
|
||||
Success bool `json:"success"`
|
||||
NewFilePath string `json:"new_file_path,omitempty"`
|
||||
NewFileURI string `json:"new_file_uri,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Additional metadata that may have changed
|
||||
BitDepth int `json:"bit_depth,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
BitDepth int `json:"bit_depth,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
}
|
||||
|
||||
type PostProcessInput struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
IsSAF bool `json:"is_saf,omitempty"`
|
||||
}
|
||||
|
||||
// PostProcessTimeout is longer for post-processing (2 minutes)
|
||||
const PostProcessTimeout = 2 * time.Minute
|
||||
|
||||
// PostProcess runs post-processing hooks on a downloaded file
|
||||
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||
if !p.extension.Manifest.HasPostProcessing() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||
@@ -1478,7 +1395,6 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -1532,9 +1448,75 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
||||
return &postResult, nil
|
||||
}
|
||||
|
||||
// ==================== Extension Manager Advanced Methods ====================
|
||||
func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||
if !p.extension.Manifest.HasPostProcessing() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||
}
|
||||
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
filePath := input.Path
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined') {
|
||||
if (typeof extension.postProcessV2 === 'function') {
|
||||
return extension.postProcessV2(%s, %s, %q);
|
||||
}
|
||||
if (typeof extension.postProcess === 'function') {
|
||||
return extension.postProcess(%q, %s, %q);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, string(inputJSON), string(metadataJSON), hookID, filePath, string(metadataJSON), hookID)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if IsTimeoutError(err) {
|
||||
errMsg = "postProcess timeout: extension took too long to complete"
|
||||
}
|
||||
return &PostProcessResult{
|
||||
Success: false,
|
||||
Error: errMsg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return &PostProcessResult{
|
||||
Success: false,
|
||||
Error: "postProcess returned null",
|
||||
}, nil
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
return &PostProcessResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to marshal result: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var postResult PostProcessResult
|
||||
if err := json.Unmarshal(jsonBytes, &postResult); err != nil {
|
||||
return &PostProcessResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to parse result: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &postResult, nil
|
||||
}
|
||||
|
||||
// GetSearchProviders returns all extensions that provide custom search
|
||||
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -1548,7 +1530,6 @@ func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
||||
return providers
|
||||
}
|
||||
|
||||
// GetURLHandlers returns all extensions that handle custom URLs
|
||||
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -1562,7 +1543,6 @@ func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
|
||||
return providers
|
||||
}
|
||||
|
||||
// FindURLHandler finds an extension that can handle the given URL
|
||||
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -1575,14 +1555,11 @@ func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtURLHandleResultWithExtID wraps ExtURLHandleResult with extension ID for gomobile compatibility
|
||||
type ExtURLHandleResultWithExtID struct {
|
||||
Result *ExtURLHandleResult
|
||||
ExtensionID string
|
||||
}
|
||||
|
||||
// HandleURLWithExtension tries to handle a URL with any matching extension
|
||||
// Returns result with extension ID, or error if no handler found
|
||||
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
|
||||
handler := m.FindURLHandler(url)
|
||||
if handler == nil {
|
||||
@@ -1662,3 +1639,58 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
||||
|
||||
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
|
||||
}
|
||||
|
||||
// RunPostProcessingV2 runs all enabled post-processing hooks on a file input.
|
||||
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||
providers := m.GetPostProcessingProviders()
|
||||
if len(providers) == 0 {
|
||||
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
|
||||
}
|
||||
|
||||
currentInput := input
|
||||
for _, provider := range providers {
|
||||
hooks := provider.extension.Manifest.GetPostProcessingHooks()
|
||||
for _, hook := range hooks {
|
||||
if !hook.DefaultEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(currentInput.Path))
|
||||
if ext == "" && currentInput.Name != "" {
|
||||
ext = strings.ToLower(filepath.Ext(currentInput.Name))
|
||||
}
|
||||
if len(hook.SupportedFormats) > 0 && ext != "" {
|
||||
supported := false
|
||||
for _, format := range hook.SupportedFormats {
|
||||
if "."+format == ext || format == ext[1:] {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[PostProcessV2] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentInput.Path)
|
||||
|
||||
result, err := provider.PostProcessV2(currentInput, metadata, hook.ID)
|
||||
if err != nil {
|
||||
GoLog("[PostProcessV2] Hook %s failed: %v\n", hook.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if result.Success && result.NewFilePath != "" {
|
||||
currentInput.Path = result.NewFilePath
|
||||
if currentInput.Name == "" {
|
||||
currentInput.Name = filepath.Base(result.NewFilePath)
|
||||
}
|
||||
}
|
||||
if result.Success && result.NewFileURI != "" {
|
||||
currentInput.URI = result.NewFileURI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -103,8 +106,16 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// Validate redirect target domain against allowed domains
|
||||
if req.URL.Scheme != "https" {
|
||||
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||
}
|
||||
|
||||
domain := req.URL.Hostname()
|
||||
if domain == "" {
|
||||
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
|
||||
return fmt.Errorf("redirect blocked: hostname is required")
|
||||
}
|
||||
if !ext.Manifest.IsDomainAllowed(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain}
|
||||
@@ -113,7 +124,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||
}
|
||||
// Default redirect limit (10)
|
||||
if len(via) >= 10 {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
@@ -139,35 +149,48 @@ func (e *RedirectBlockedError) Error() string {
|
||||
|
||||
// isPrivateIP checks if a hostname resolves to a private/local IP address
|
||||
func isPrivateIP(host string) bool {
|
||||
// Block common private network patterns
|
||||
// This is a simple check - for production, consider DNS resolution
|
||||
privatePatterns := []string{
|
||||
"localhost",
|
||||
"127.",
|
||||
"10.",
|
||||
"172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.20.", "172.21.", "172.22.", "172.23.",
|
||||
"172.24.", "172.25.", "172.26.", "172.27.",
|
||||
"172.28.", "172.29.", "172.30.", "172.31.",
|
||||
"192.168.",
|
||||
"169.254.",
|
||||
"::1",
|
||||
"fc00:",
|
||||
"fe80:",
|
||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||
if hostLower == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
hostLower := host
|
||||
for _, pattern := range privatePatterns {
|
||||
if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern {
|
||||
if hostLower == "localhost" || strings.HasSuffix(hostLower, ".local") {
|
||||
return true
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(hostLower); ip != nil {
|
||||
return isPrivateIPAddr(ip)
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(hostLower)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if isPrivateIPAddr(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Also block .local domains
|
||||
if len(host) > 6 && host[len(host)-6:] == ".local" {
|
||||
return false
|
||||
}
|
||||
|
||||
func isPrivateIPAddr(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
if !ip.IsGlobalUnicast() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -202,7 +225,6 @@ func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
r.vm = vm
|
||||
|
||||
// HTTP client (sandboxed to allowed domains)
|
||||
httpObj := vm.NewObject()
|
||||
httpObj.Set("get", r.httpGet)
|
||||
httpObj.Set("post", r.httpPost)
|
||||
@@ -239,7 +261,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||
vm.Set("auth", authObj)
|
||||
|
||||
// File operations (sandboxed)
|
||||
fileObj := vm.NewObject()
|
||||
fileObj.Set("download", r.fileDownload)
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
@@ -257,7 +278,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
ffmpegObj.Set("convert", r.ffmpegConvert)
|
||||
vm.Set("ffmpeg", ffmpegObj)
|
||||
|
||||
// Track matching API
|
||||
matchingObj := vm.NewObject()
|
||||
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
||||
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
||||
|
||||
@@ -18,6 +18,43 @@ import (
|
||||
|
||||
// ==================== Auth API (OAuth Support) ====================
|
||||
|
||||
func validateExtensionAuthURL(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid auth URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("invalid auth URL: only https is allowed")
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("invalid auth URL: hostname is required")
|
||||
}
|
||||
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("invalid auth URL: embedded credentials are not allowed")
|
||||
}
|
||||
|
||||
if isPrivateIP(host) {
|
||||
return fmt.Errorf("invalid auth URL: private/local network is not allowed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func summarizeURLForLog(urlStr string) string {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return urlStr
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return parsed.Scheme + "://"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -32,6 +69,13 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
callbackURL = call.Arguments[1].String()
|
||||
}
|
||||
|
||||
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
@@ -50,7 +94,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
@@ -121,7 +165,6 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// authIsAuthenticated checks if extension has valid auth
|
||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -194,7 +237,6 @@ func generatePKCEChallenge(verifier string) string {
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
// Default length is 64 characters
|
||||
length := 64
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||
@@ -247,9 +289,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// authStartOAuthWithPKCE is a high-level helper that generates PKCE and opens OAuth URL
|
||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||
// Returns: { success, authUrl, pkce: { verifier, challenge } }
|
||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -267,7 +307,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
})
|
||||
}
|
||||
|
||||
// Required fields
|
||||
authURL, _ := config["authUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
@@ -278,12 +317,16 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
"error": "authUrl, clientId, and redirectUri are required",
|
||||
})
|
||||
}
|
||||
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
scope, _ := config["scope"].(string)
|
||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||
|
||||
// Generate PKCE
|
||||
verifier, err := generatePKCEVerifier(64)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -293,7 +336,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
}
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
// Store PKCE in auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
@@ -302,10 +344,9 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
}
|
||||
state.PKCEVerifier = verifier
|
||||
state.PKCEChallenge = challenge
|
||||
state.AuthCode = "" // Clear any previous auth code
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
// Build OAuth URL with PKCE parameters
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -325,7 +366,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
query.Set("scope", scope)
|
||||
}
|
||||
|
||||
// Add extra params
|
||||
for k, v := range extraParams {
|
||||
query.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
@@ -333,7 +373,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
fullAuthURL := parsedURL.String()
|
||||
|
||||
// Store pending auth request for Flutter
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
@@ -342,7 +381,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
@@ -452,13 +491,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
bodyPreview := sanitizeSensitiveLogText(string(body))
|
||||
if len(bodyPreview) > 1000 {
|
||||
bodyPreview = bodyPreview[:1000] + "...[truncated]"
|
||||
}
|
||||
|
||||
var tokenResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||
"body": string(body),
|
||||
"body": bodyPreview,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -479,7 +522,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no access_token in response",
|
||||
"body": string(body),
|
||||
"body": bodyPreview,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
|
||||
command := call.Arguments[0].String()
|
||||
|
||||
// Generate unique command ID
|
||||
ffmpegCommandsMu.Lock()
|
||||
ffmpegCommandID++
|
||||
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
|
||||
@@ -77,7 +76,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
|
||||
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
|
||||
|
||||
// Wait for completion (with timeout)
|
||||
timeout := 5 * time.Minute
|
||||
start := time.Now()
|
||||
for {
|
||||
@@ -97,7 +95,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
ffmpegCommandsMu.RUnlock()
|
||||
|
||||
// Cleanup
|
||||
ClearFFmpegCommand(cmdID)
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
@@ -124,7 +121,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
|
||||
filePath := call.Arguments[0].String()
|
||||
|
||||
// Use Go's built-in audio quality function
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -153,7 +149,6 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
inputPath := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
// Get options if provided
|
||||
options := map[string]interface{}{}
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
|
||||
@@ -161,36 +156,29 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Build FFmpeg command
|
||||
var cmdParts []string
|
||||
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
|
||||
|
||||
// Audio codec
|
||||
if codec, ok := options["codec"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-c:a", codec)
|
||||
}
|
||||
|
||||
// Bitrate
|
||||
if bitrate, ok := options["bitrate"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-b:a", bitrate)
|
||||
}
|
||||
|
||||
// Sample rate
|
||||
if sampleRate, ok := options["sample_rate"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
|
||||
}
|
||||
|
||||
// Channels
|
||||
if channels, ok := options["channels"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
|
||||
}
|
||||
|
||||
// Overwrite output
|
||||
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
|
||||
|
||||
command := strings.Join(cmdParts, " ")
|
||||
|
||||
// Execute via ffmpegExecute
|
||||
execCall := goja.FunctionCall{
|
||||
Arguments: []goja.Value{r.vm.ToValue(command)},
|
||||
}
|
||||
|
||||
@@ -41,15 +41,40 @@ func isPathInAllowedDirs(absPath string) bool {
|
||||
defer allowedDownloadDirsMu.RUnlock()
|
||||
|
||||
for _, allowedDir := range allowedDownloadDirs {
|
||||
if strings.HasPrefix(absPath, allowedDir) {
|
||||
if isPathWithinBase(allowedDir, absPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPathWithinBase(baseDir, targetPath string) bool {
|
||||
baseAbs, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
targetAbs, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(baseAbs, targetAbs)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel = filepath.Clean(rel)
|
||||
if rel == "." {
|
||||
return true
|
||||
}
|
||||
|
||||
prefix := ".." + string(filepath.Separator)
|
||||
if rel == ".." || strings.HasPrefix(rel, prefix) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
// Check if extension has file permission
|
||||
if !r.manifest.Permissions.File {
|
||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||
}
|
||||
@@ -77,7 +102,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
}
|
||||
|
||||
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||
if !strings.HasPrefix(absPath, absDataDir) {
|
||||
if !isPathWithinBase(absDataDir, absPath) {
|
||||
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||
}
|
||||
|
||||
@@ -323,7 +348,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
|
||||
@@ -26,9 +26,21 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
domain := parsed.Hostname()
|
||||
if parsed.Scheme == "" {
|
||||
return fmt.Errorf("invalid URL: scheme is required")
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("network access denied: only https is allowed")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("invalid URL: embedded credentials are not allowed")
|
||||
}
|
||||
|
||||
domain := parsed.Hostname()
|
||||
if domain == "" {
|
||||
return fmt.Errorf("invalid URL: hostname is required")
|
||||
}
|
||||
|
||||
// Block private/local network access (SSRF protection)
|
||||
if isPrivateIP(domain) {
|
||||
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
// ==================== Track Matching API ====================
|
||||
|
||||
// matchingCompareStrings compares two strings with fuzzy matching
|
||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(0.0)
|
||||
@@ -22,12 +21,10 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
||||
return r.vm.ToValue(1.0)
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance-based similarity
|
||||
similarity := calculateStringSimilarity(str1, str2)
|
||||
return r.vm.ToValue(similarity)
|
||||
}
|
||||
|
||||
// matchingCompareDuration compares two durations with tolerance
|
||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -36,8 +33,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
||||
dur1 := int(call.Arguments[0].ToInteger())
|
||||
dur2 := int(call.Arguments[1].ToInteger())
|
||||
|
||||
// Default tolerance: 3 seconds
|
||||
tolerance := 3000 // milliseconds
|
||||
tolerance := 3000
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
||||
tolerance = int(call.Arguments[2].ToInteger())
|
||||
}
|
||||
@@ -50,7 +46,6 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(diff <= tolerance)
|
||||
}
|
||||
|
||||
// matchingNormalizeString normalizes a string for comparison
|
||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -61,7 +56,6 @@ func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(normalized)
|
||||
}
|
||||
|
||||
// calculateStringSimilarity calculates similarity between two strings (0-1)
|
||||
func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
if len(s1) == 0 && len(s2) == 0 {
|
||||
return 1.0
|
||||
@@ -70,7 +64,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Use Levenshtein distance
|
||||
distance := levenshteinDistance(s1, s2)
|
||||
maxLen := len(s1)
|
||||
if len(s2) > maxLen {
|
||||
@@ -80,7 +73,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
return 1.0 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the Levenshtein distance between two strings
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
@@ -89,7 +81,6 @@ func levenshteinDistance(s1, s2 string) int {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
// Create matrix
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
@@ -99,7 +90,6 @@ func levenshteinDistance(s1, s2 string) int {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
// Fill matrix
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 1
|
||||
@@ -107,9 +97,9 @@ func levenshteinDistance(s1, s2 string) int {
|
||||
cost = 0
|
||||
}
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1, // deletion
|
||||
matrix[i][j-1]+1, // insertion
|
||||
matrix[i-1][j-1]+cost, // substitution
|
||||
matrix[i-1][j]+1,
|
||||
matrix[i][j-1]+1,
|
||||
matrix[i-1][j-1]+cost,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -117,12 +107,9 @@ func levenshteinDistance(s1, s2 string) int {
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
// normalizeStringForMatching normalizes a string for comparison
|
||||
func normalizeStringForMatching(s string) string {
|
||||
// Convert to lowercase
|
||||
s = strings.ToLower(s)
|
||||
|
||||
// Remove common suffixes/prefixes
|
||||
suffixes := []string{
|
||||
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
||||
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
||||
@@ -136,7 +123,6 @@ func normalizeStringForMatching(s string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove special characters
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
||||
@@ -144,7 +130,6 @@ func normalizeStringForMatching(s string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse multiple spaces
|
||||
s = strings.Join(strings.Fields(result.String()), " ")
|
||||
|
||||
return strings.TrimSpace(s)
|
||||
|
||||
@@ -25,14 +25,11 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Parse options
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
@@ -40,7 +37,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
// Method
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
@@ -61,7 +57,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Headers
|
||||
if h, ok := opts["headers"]; ok && h != nil {
|
||||
switch hv := h.(type) {
|
||||
case map[string]interface{}:
|
||||
@@ -73,7 +68,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
@@ -84,11 +78,9 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Set defaults if not provided
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
@@ -96,20 +88,17 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
@@ -119,7 +108,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create Response object (browser-compatible)
|
||||
responseObj := r.vm.NewObject()
|
||||
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||
responseObj.Set("status", resp.StatusCode)
|
||||
@@ -127,15 +115,12 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
responseObj.Set("headers", respHeaders)
|
||||
responseObj.Set("url", urlStr)
|
||||
|
||||
// Store body for methods
|
||||
bodyString := string(body)
|
||||
|
||||
// text() method - returns body as string
|
||||
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(bodyString)
|
||||
})
|
||||
|
||||
// json() method - parses body as JSON
|
||||
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
@@ -145,9 +130,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(result)
|
||||
})
|
||||
|
||||
// arrayBuffer() method - returns body as array (simplified)
|
||||
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
||||
// Return as array of bytes
|
||||
byteArray := make([]interface{}, len(body))
|
||||
for i, b := range body {
|
||||
byteArray[i] = int(b)
|
||||
@@ -182,7 +165,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
input := call.Arguments[0].String()
|
||||
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
// Try URL-safe base64
|
||||
decoded, err = base64.URLEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
||||
@@ -203,12 +185,10 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
|
||||
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
// TextEncoder constructor
|
||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
encoder := call.This
|
||||
encoder.Set("encoding", "utf-8")
|
||||
|
||||
// encode() method - string to Uint8Array
|
||||
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]byte{})
|
||||
@@ -216,7 +196,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
input := call.Arguments[0].String()
|
||||
bytes := []byte(input)
|
||||
|
||||
// Return as array (Uint8Array-like)
|
||||
result := make([]interface{}, len(bytes))
|
||||
for i, b := range bytes {
|
||||
result[i] = int(b)
|
||||
@@ -224,7 +203,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
|
||||
// encodeInto() method
|
||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||
// Simplified implementation
|
||||
if len(call.Arguments) < 2 {
|
||||
@@ -240,11 +218,9 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
return nil
|
||||
})
|
||||
|
||||
// TextDecoder constructor
|
||||
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
decoder := call.This
|
||||
|
||||
// Get encoding from arguments (default: utf-8)
|
||||
encoding := "utf-8"
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
encoding = call.Arguments[0].String()
|
||||
@@ -253,13 +229,11 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
decoder.Set("fatal", false)
|
||||
decoder.Set("ignoreBOM", false)
|
||||
|
||||
// decode() method - Uint8Array to string
|
||||
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
// Handle different input types
|
||||
input := call.Arguments[0].Export()
|
||||
var bytes []byte
|
||||
|
||||
@@ -279,7 +253,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
}
|
||||
}
|
||||
case string:
|
||||
// Already a string, just return it
|
||||
return vm.ToValue(v)
|
||||
default:
|
||||
return vm.ToValue("")
|
||||
@@ -292,7 +265,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
})
|
||||
}
|
||||
|
||||
// registerURLClass registers the URL class for URL parsing
|
||||
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||
urlObj := call.This
|
||||
@@ -304,7 +276,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Handle relative URLs with base
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||
baseStr := call.Arguments[1].String()
|
||||
baseURL, err := url.Parse(baseStr)
|
||||
@@ -322,7 +293,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set URL properties
|
||||
urlObj.Set("href", parsed.String())
|
||||
urlObj.Set("protocol", parsed.Scheme+":")
|
||||
urlObj.Set("host", parsed.Host)
|
||||
@@ -342,10 +312,9 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
password, _ := parsed.User.Password()
|
||||
urlObj.Set("password", password)
|
||||
|
||||
// searchParams object
|
||||
searchParams := vm.NewObject()
|
||||
queryValues := parsed.Query()
|
||||
|
||||
searchParams := vm.NewObject()
|
||||
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Null()
|
||||
@@ -379,12 +348,10 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
|
||||
urlObj.Set("searchParams", searchParams)
|
||||
|
||||
// toString method
|
||||
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
|
||||
// toJSON method
|
||||
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
@@ -392,17 +359,14 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
return nil
|
||||
})
|
||||
|
||||
// URLSearchParams constructor
|
||||
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
|
||||
paramsObj := call.This
|
||||
values := url.Values{}
|
||||
|
||||
// Parse initial value if provided
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
init := call.Arguments[0].Export()
|
||||
switch v := init.(type) {
|
||||
case string:
|
||||
// Parse query string
|
||||
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
|
||||
values = parsed
|
||||
case map[string]interface{}:
|
||||
@@ -468,10 +432,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
// registerJSONGlobal ensures JSON global is properly set up
|
||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||
// JSON is already built-in to Goja, but we can enhance it
|
||||
// This ensures JSON.parse and JSON.stringify work as expected
|
||||
|
||||
// The built-in JSON object should already work, but let's verify
|
||||
// and add any missing functionality if needed
|
||||
jsonScript := `
|
||||
if (typeof JSON === 'undefined') {
|
||||
var JSON = {
|
||||
|
||||
@@ -46,7 +46,7 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(storagePath, data, 0644)
|
||||
return os.WriteFile(storagePath, data, 0600)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
@@ -15,7 +15,6 @@ type ExtensionSettingsStore struct {
|
||||
settings map[string]map[string]interface{} // extensionID -> settings
|
||||
}
|
||||
|
||||
// Global settings store
|
||||
var (
|
||||
globalSettingsStore *ExtensionSettingsStore
|
||||
globalSettingsStoreOnce sync.Once
|
||||
@@ -129,7 +128,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Return a copy
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range extSettings {
|
||||
result[k] = v
|
||||
@@ -156,7 +154,6 @@ func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]
|
||||
|
||||
s.settings[extensionID] = settings
|
||||
|
||||
// Persist to disk
|
||||
return s.saveSettings(extensionID, settings)
|
||||
}
|
||||
|
||||
@@ -171,7 +168,6 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||
|
||||
delete(extSettings, key)
|
||||
|
||||
// Persist to disk
|
||||
return s.saveSettings(extensionID, extSettings)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Extension categories
|
||||
const (
|
||||
CategoryMetadata = "metadata"
|
||||
CategoryDownload = "download"
|
||||
@@ -145,7 +145,6 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
cacheDir: cacheDir,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
// Try to load from disk cache
|
||||
extensionStore.loadDiskCache()
|
||||
}
|
||||
return extensionStore
|
||||
@@ -208,18 +207,20 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
// Return cached if valid and not forcing refresh
|
||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||
return s.cache, nil
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(s.registryURL, "registry"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(s.registryURL)
|
||||
if err != nil {
|
||||
// Return cached data if available on network error
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
@@ -303,6 +304,10 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
@@ -332,6 +337,20 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireHTTPSURL(rawURL string, context string) error {
|
||||
if rawURL == "" {
|
||||
return fmt.Errorf("%s URL is empty", context)
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return fmt.Errorf("%s URL is invalid: %s", context, rawURL)
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("%s URL must use https: %s", context, rawURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetCategories() []string {
|
||||
return []string{
|
||||
CategoryMetadata,
|
||||
|
||||
@@ -112,7 +112,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
// Test allowed domains
|
||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||
}
|
||||
@@ -121,7 +120,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
||||
}
|
||||
|
||||
// Test blocked domains
|
||||
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
||||
t.Error("Expected blocked.com to be denied")
|
||||
}
|
||||
@@ -139,7 +137,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: true, // Enable file permission for test
|
||||
File: true,
|
||||
},
|
||||
},
|
||||
DataDir: tempDir,
|
||||
@@ -147,7 +145,6 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
// Test valid path within sandbox
|
||||
validPath, err := runtime.validatePath("test.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
||||
@@ -156,13 +153,11 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
t.Error("Expected non-empty path")
|
||||
}
|
||||
|
||||
// Test path traversal attack
|
||||
_, err = runtime.validatePath("../../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("Expected path traversal to be blocked")
|
||||
}
|
||||
|
||||
// Test nested path within sandbox (should be allowed)
|
||||
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
||||
@@ -171,26 +166,23 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
t.Error("Expected non-empty nested path")
|
||||
}
|
||||
|
||||
// Test absolute path should be blocked (security fix)
|
||||
// Use platform-appropriate absolute path
|
||||
var absPath string
|
||||
if filepath.IsAbs("C:\\Windows\\System32") {
|
||||
absPath = "C:\\Windows\\System32\\test.txt" // Windows
|
||||
absPath = "C:\\Windows\\System32\\test.txt"
|
||||
} else {
|
||||
absPath = "/etc/passwd" // Unix
|
||||
absPath = "/etc/passwd"
|
||||
}
|
||||
_, err = runtime.validatePath(absPath)
|
||||
if err == nil {
|
||||
t.Error("Expected absolute path to be blocked")
|
||||
}
|
||||
|
||||
// Test that extension without file permission is blocked
|
||||
extNoFile := &LoadedExtension{
|
||||
ID: "test-ext-no-file",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext-no-file",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: false, // No file permission
|
||||
File: false,
|
||||
},
|
||||
},
|
||||
DataDir: tempDir,
|
||||
@@ -215,7 +207,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
vm := goja.New()
|
||||
runtime.RegisterAPIs(vm)
|
||||
|
||||
// Test base64 encode/decode
|
||||
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
||||
if err != nil {
|
||||
t.Fatalf("base64Encode failed: %v", err)
|
||||
@@ -232,7 +223,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
t.Errorf("Expected 'hello', got '%s'", result.String())
|
||||
}
|
||||
|
||||
// Test MD5
|
||||
result, err = vm.RunString(`utils.md5("hello")`)
|
||||
if err != nil {
|
||||
t.Fatalf("md5 failed: %v", err)
|
||||
@@ -241,7 +231,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
||||
}
|
||||
|
||||
// Test JSON parse/stringify
|
||||
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
||||
if err != nil {
|
||||
t.Fatalf("stringifyJSON failed: %v", err)
|
||||
@@ -267,7 +256,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
// Test that private IPs are blocked (SSRF protection)
|
||||
privateIPs := []string{
|
||||
"http://localhost/admin",
|
||||
"http://127.0.0.1/admin",
|
||||
@@ -285,7 +273,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test that allowed public domain still works
|
||||
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
||||
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
||||
}
|
||||
@@ -296,7 +283,6 @@ func TestIsPrivateIP(t *testing.T) {
|
||||
host string
|
||||
expected bool
|
||||
}{
|
||||
// Private IPs should be blocked
|
||||
{"localhost", true},
|
||||
{"127.0.0.1", true},
|
||||
{"127.0.0.2", true},
|
||||
@@ -306,18 +292,17 @@ func TestIsPrivateIP(t *testing.T) {
|
||||
{"172.31.255.255", true},
|
||||
{"192.168.0.1", true},
|
||||
{"192.168.255.255", true},
|
||||
{"169.254.169.254", true}, // AWS metadata
|
||||
{"169.254.169.254", true},
|
||||
{"router.local", true},
|
||||
{"mydevice.local", true},
|
||||
|
||||
// Public IPs should be allowed
|
||||
{"8.8.8.8", false},
|
||||
{"1.1.1.1", false},
|
||||
{"api.example.com", false},
|
||||
{"google.com", false},
|
||||
{"172.15.0.1", false}, // Just outside 172.16-31 range
|
||||
{"172.32.0.1", false}, // Just outside 172.16-31 range
|
||||
{"192.167.0.1", false}, // Not 192.168.x.x
|
||||
{"172.15.0.1", false},
|
||||
{"172.32.0.1", false},
|
||||
{"192.167.0.1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -4,13 +4,13 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// JSExecutionError represents an error during JS execution
|
||||
type JSExecutionError struct {
|
||||
Message string
|
||||
IsTimeout bool
|
||||
@@ -20,8 +20,6 @@ func (e *JSExecutionError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// RunWithTimeout executes JavaScript code with a timeout
|
||||
// Returns the result value and any error (including timeout)
|
||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultJSTimeout
|
||||
@@ -30,22 +28,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Channel to receive result
|
||||
type result struct {
|
||||
value goja.Value
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
|
||||
// Track if we've interrupted
|
||||
var interrupted bool
|
||||
var interruptMu sync.Mutex
|
||||
|
||||
// Run script in goroutine
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Check if this was our interrupt
|
||||
interruptMu.Lock()
|
||||
wasInterrupted := interrupted
|
||||
interruptMu.Unlock()
|
||||
@@ -56,6 +50,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
IsTimeout: true,
|
||||
}}
|
||||
} else {
|
||||
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||
}
|
||||
}
|
||||
@@ -65,22 +60,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
resultCh <- result{val, err}
|
||||
}()
|
||||
|
||||
// Wait for result or timeout
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
return res.value, res.err
|
||||
case <-ctx.Done():
|
||||
// Timeout - interrupt the VM
|
||||
interruptMu.Lock()
|
||||
interrupted = true
|
||||
interruptMu.Unlock()
|
||||
|
||||
vm.Interrupt("execution timeout")
|
||||
|
||||
// Wait a bit for the goroutine to finish
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
// If we got a result after interrupt, it might be the timeout error
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
@@ -89,7 +80,6 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
IsTimeout: true,
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
// Force return timeout error
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
@@ -109,7 +99,6 @@ func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Dura
|
||||
return result, err
|
||||
}
|
||||
|
||||
// IsTimeoutError checks if an error is a timeout error
|
||||
func IsTimeoutError(err error) bool {
|
||||
if jsErr, ok := err.(*JSExecutionError); ok {
|
||||
return jsErr.IsTimeout
|
||||
|
||||
+5
-5
@@ -2,15 +2,15 @@ module github.com/zarz/spotiflac_android/go_backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.25.6
|
||||
toolchain go1.25.7
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/go-flac/flacpicture v0.3.0
|
||||
github.com/go-flac/flacvorbis v0.2.0
|
||||
github.com/go-flac/go-flac v1.0.0
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3
|
||||
golang.org/x/net v0.49.0
|
||||
)
|
||||
|
||||
|
||||
+16
-8
@@ -2,16 +2,18 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
||||
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
||||
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
||||
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
||||
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
|
||||
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
|
||||
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
@@ -20,12 +22,16 @@ github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU=
|
||||
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I=
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
@@ -40,3 +46,5 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+31
-4
@@ -16,7 +16,6 @@ import (
|
||||
)
|
||||
|
||||
func getRandomUserAgent() string {
|
||||
// Chrome version 120-145 (modern range)
|
||||
chromeVersion := rand.Intn(26) + 120
|
||||
chromeBuild := rand.Intn(1500) + 6000
|
||||
chromePatch := rand.Intn(200) + 100
|
||||
@@ -56,6 +55,27 @@ var sharedTransport = &http.Transport{
|
||||
DisableCompression: true,
|
||||
}
|
||||
|
||||
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
|
||||
// Isolated from download traffic so that download failures cannot poison
|
||||
// the connection pool used by metadata enrichment.
|
||||
var metadataTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 30,
|
||||
MaxIdleConnsPerHost: 5,
|
||||
MaxConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
WriteBufferSize: 32 * 1024,
|
||||
ReadBufferSize: 32 * 1024,
|
||||
DisableCompression: true,
|
||||
}
|
||||
|
||||
var sharedClient = &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Timeout: DefaultTimeout,
|
||||
@@ -73,6 +93,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
|
||||
// Use this for API calls that should not be affected by download traffic.
|
||||
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: metadataTransport,
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func GetSharedClient() *http.Client {
|
||||
return sharedClient
|
||||
}
|
||||
@@ -83,6 +112,7 @@ func GetDownloadClient() *http.Client {
|
||||
|
||||
func CloseIdleConnections() {
|
||||
sharedTransport.CloseIdleConnections()
|
||||
metadataTransport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// Also checks for ISP blocking on errors
|
||||
@@ -118,7 +148,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
requestURL := req.URL.String()
|
||||
|
||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||
// Clone request for retry (body needs to be re-readable)
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
@@ -126,9 +155,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
|
||||
// Check for ISP blocking on network errors
|
||||
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||
// Don't retry if ISP blocking is detected - it won't help
|
||||
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ func newUTLSTransport() *utlsTransport {
|
||||
}
|
||||
|
||||
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// For non-HTTPS, use standard transport
|
||||
if req.URL.Scheme != "https" {
|
||||
return sharedTransport.RoundTrip(req)
|
||||
}
|
||||
@@ -44,29 +43,24 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
port := t.getPort(req.URL)
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
// Dial TCP connection
|
||||
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create uTLS connection with Chrome fingerprint (supports HTTP/2 ALPN)
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
NextProtos: []string{"h2", "http/1.1"}, // Prefer HTTP/2
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}, utls.HelloChrome_Auto)
|
||||
|
||||
// Perform TLS handshake
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if server supports HTTP/2
|
||||
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
|
||||
|
||||
if negotiatedProto == "h2" {
|
||||
// Use HTTP/2 transport
|
||||
h2Transport := &http2.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
|
||||
return tlsConn, nil
|
||||
@@ -77,7 +71,6 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return h2Transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
// Fallback to HTTP/1.1
|
||||
transport := &http.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return tlsConn, nil
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LibraryScanResult represents metadata from a scanned audio file
|
||||
type LibraryScanResult struct {
|
||||
ID string `json:"id"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
AlbumArtist string `json:"albumArtist,omitempty"`
|
||||
FilePath string `json:"filePath"`
|
||||
CoverPath string `json:"coverPath,omitempty"`
|
||||
ScannedAt string `json:"scannedAt"`
|
||||
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
TrackNumber int `json:"trackNumber,omitempty"`
|
||||
DiscNumber int `json:"discNumber,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||
BitDepth int `json:"bitDepth,omitempty"`
|
||||
SampleRate int `json:"sampleRate,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
type LibraryScanProgress struct {
|
||||
TotalFiles int `json:"total_files"`
|
||||
ScannedFiles int `json:"scanned_files"`
|
||||
CurrentFile string `json:"current_file"`
|
||||
ErrorCount int `json:"error_count"`
|
||||
ProgressPct float64 `json:"progress_pct"`
|
||||
IsComplete bool `json:"is_complete"`
|
||||
}
|
||||
|
||||
// IncrementalScanResult contains results of an incremental library scan
|
||||
type IncrementalScanResult struct {
|
||||
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
|
||||
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
|
||||
SkippedCount int `json:"skippedCount"` // Files that were unchanged
|
||||
TotalFiles int `json:"totalFiles"` // Total files in folder
|
||||
}
|
||||
|
||||
var (
|
||||
libraryScanProgress LibraryScanProgress
|
||||
libraryScanProgressMu sync.RWMutex
|
||||
libraryScanCancel chan struct{}
|
||||
libraryScanCancelMu sync.Mutex
|
||||
libraryCoverCacheDir string
|
||||
libraryCoverCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
var supportedAudioFormats = map[string]bool{
|
||||
".flac": true,
|
||||
".m4a": true,
|
||||
".mp3": true,
|
||||
".opus": true,
|
||||
".ogg": true,
|
||||
}
|
||||
|
||||
func SetLibraryCoverCacheDir(cacheDir string) {
|
||||
libraryCoverCacheMu.Lock()
|
||||
libraryCoverCacheDir = cacheDir
|
||||
libraryCoverCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
if folderPath == "" {
|
||||
return "[]", fmt.Errorf("folder path is empty")
|
||||
}
|
||||
|
||||
info, err := os.Stat(folderPath)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("folder not found: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "[]", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress = LibraryScanProgress{}
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
libraryScanCancelMu.Lock()
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
}
|
||||
libraryScanCancel = make(chan struct{})
|
||||
cancelCh := libraryScanCancel
|
||||
libraryScanCancelMu.Unlock()
|
||||
|
||||
var audioFiles []string
|
||||
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if supportedAudioFormats[ext] {
|
||||
audioFiles = append(audioFiles, path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
|
||||
totalFiles := len(audioFiles)
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
if totalFiles == 0 {
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgressMu.Unlock()
|
||||
return "[]", nil
|
||||
}
|
||||
|
||||
GoLog("[LibraryScan] Found %d audio files to scan\n", totalFiles)
|
||||
|
||||
results := make([]LibraryScanResult, 0, totalFiles)
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
errorCount := 0
|
||||
|
||||
for i, filePath := range audioFiles {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return "[]", fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = i + 1
|
||||
libraryScanProgress.CurrentFile = filepath.Base(filePath)
|
||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
result, err := scanAudioFile(filePath, scanTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ErrorCount = errorCount
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
GoLog("[LibraryScan] Scan complete: %d tracks found, %d errors\n", len(results), errorCount)
|
||||
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("failed to marshal results: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
result := &LibraryScanResult{
|
||||
ID: generateLibraryID(filePath),
|
||||
FilePath: filePath,
|
||||
ScannedAt: scanTime,
|
||||
Format: strings.TrimPrefix(ext, "."),
|
||||
}
|
||||
|
||||
// Get file modification time
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
result.FileModTime = info.ModTime().UnixMilli()
|
||||
}
|
||||
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" && ext != ".m4a" {
|
||||
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
|
||||
if err == nil && coverPath != "" {
|
||||
result.CoverPath = coverPath
|
||||
}
|
||||
}
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return scanFLACFile(filePath, result)
|
||||
case ".m4a":
|
||||
return scanM4AFile(filePath, result)
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result)
|
||||
case ".opus", ".ogg":
|
||||
return scanOggFile(filePath, result)
|
||||
default:
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
}
|
||||
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.ReleaseDate = metadata.Date
|
||||
result.Genre = metadata.Genre
|
||||
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
}
|
||||
}
|
||||
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
if result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadID3Tags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.Genre = metadata.Genre
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
} else {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.ISRC = metadata.ISRC
|
||||
|
||||
quality, err := GetMP3Quality(filePath)
|
||||
if err == nil {
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.Duration = quality.Duration
|
||||
}
|
||||
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
if result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadOggVorbisComments(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.Genre = metadata.Genre
|
||||
result.ReleaseDate = metadata.Date
|
||||
|
||||
quality, err := GetOggQuality(filePath)
|
||||
if err == nil {
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.Duration = quality.Duration
|
||||
}
|
||||
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
if result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
|
||||
parts := strings.SplitN(filename, " - ", 2)
|
||||
if len(parts) == 2 {
|
||||
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
|
||||
result.TrackName = parts[1]
|
||||
result.ArtistName = "Unknown Artist"
|
||||
} else {
|
||||
result.ArtistName = parts[0]
|
||||
result.TrackName = parts[1]
|
||||
}
|
||||
} else {
|
||||
if len(filename) > 3 && isNumeric(filename[:2]) {
|
||||
title := strings.TrimLeft(filename[2:], " .-")
|
||||
result.TrackName = title
|
||||
} else {
|
||||
result.TrackName = filename
|
||||
}
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
result.AlbumName = filepath.Base(dir)
|
||||
if result.AlbumName == "." || result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func isNumeric(s string) bool {
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
func generateLibraryID(filePath string) string {
|
||||
return fmt.Sprintf("lib_%x", hashString(filePath))
|
||||
}
|
||||
|
||||
func hashString(s string) uint32 {
|
||||
var hash uint32 = 5381
|
||||
for _, c := range s {
|
||||
hash = ((hash << 5) + hash) + uint32(c)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
func GetLibraryScanProgress() string {
|
||||
libraryScanProgressMu.RLock()
|
||||
defer libraryScanProgressMu.RUnlock()
|
||||
|
||||
jsonBytes, _ := json.Marshal(libraryScanProgress)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func CancelLibraryScan() {
|
||||
libraryScanCancelMu.Lock()
|
||||
defer libraryScanCancelMu.Unlock()
|
||||
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
libraryScanCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
func ReadAudioMetadata(filePath string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
result, err := scanAudioFile(filePath, scanTime)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||
// Only files that are new or have changed modification time will be scanned
|
||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||
if folderPath == "" {
|
||||
return "{}", fmt.Errorf("folder path is empty")
|
||||
}
|
||||
|
||||
info, err := os.Stat(folderPath)
|
||||
if err != nil {
|
||||
return "{}", fmt.Errorf("folder not found: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||
}
|
||||
|
||||
// Parse existing files map
|
||||
existingFiles := make(map[string]int64)
|
||||
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
||||
|
||||
// Reset progress
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress = LibraryScanProgress{}
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Setup cancellation
|
||||
libraryScanCancelMu.Lock()
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
}
|
||||
libraryScanCancel = make(chan struct{})
|
||||
cancelCh := libraryScanCancel
|
||||
libraryScanCancelMu.Unlock()
|
||||
|
||||
// Collect all audio files with their mod times
|
||||
type fileInfo struct {
|
||||
path string
|
||||
modTime int64
|
||||
}
|
||||
var currentFiles []fileInfo
|
||||
currentPathSet := make(map[string]bool)
|
||||
|
||||
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if supportedAudioFormats[ext] {
|
||||
currentFiles = append(currentFiles, fileInfo{
|
||||
path: path,
|
||||
modTime: info.ModTime().UnixMilli(),
|
||||
})
|
||||
currentPathSet[path] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "{}", err
|
||||
}
|
||||
|
||||
totalFiles := len(currentFiles)
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Find files to scan (new or modified)
|
||||
var filesToScan []fileInfo
|
||||
skippedCount := 0
|
||||
|
||||
for _, f := range currentFiles {
|
||||
existingModTime, exists := existingFiles[f.path]
|
||||
if !exists {
|
||||
// New file
|
||||
filesToScan = append(filesToScan, f)
|
||||
} else if f.modTime != existingModTime {
|
||||
// Modified file
|
||||
filesToScan = append(filesToScan, f)
|
||||
} else {
|
||||
// Unchanged file - skip
|
||||
skippedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Find deleted files
|
||||
var deletedPaths []string
|
||||
for existingPath := range existingFiles {
|
||||
if !currentPathSet[existingPath] {
|
||||
deletedPaths = append(deletedPaths, existingPath)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
|
||||
len(filesToScan), skippedCount, len(deletedPaths))
|
||||
|
||||
if len(filesToScan) == 0 {
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = totalFiles
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgress.ProgressPct = 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
result := IncrementalScanResult{
|
||||
Scanned: []LibraryScanResult{},
|
||||
DeletedPaths: deletedPaths,
|
||||
SkippedCount: skippedCount,
|
||||
TotalFiles: totalFiles,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Scan the files that need scanning
|
||||
results := make([]LibraryScanResult, 0, len(filesToScan))
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
errorCount := 0
|
||||
|
||||
for i, f := range filesToScan {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return "{}", fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = skippedCount + i + 1
|
||||
libraryScanProgress.CurrentFile = filepath.Base(f.path)
|
||||
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
result, err := scanAudioFile(f.path, scanTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ErrorCount = errorCount
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgress.ScannedFiles = totalFiles
|
||||
libraryScanProgress.ProgressPct = 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
|
||||
len(results), skippedCount, len(deletedPaths), errorCount)
|
||||
|
||||
scanResult := IncrementalScanResult{
|
||||
Scanned: results,
|
||||
DeletedPaths: deletedPaths,
|
||||
SkippedCount: skippedCount,
|
||||
TotalFiles: totalFiles,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(scanResult)
|
||||
if err != nil {
|
||||
return "{}", fmt.Errorf("failed to marshal results: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
+33
-16
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -22,30 +23,55 @@ type LogBuffer struct {
|
||||
loggingEnabled bool
|
||||
}
|
||||
|
||||
const (
|
||||
defaultLogBufferSize = 500
|
||||
maxLogMessageLength = 500
|
||||
)
|
||||
|
||||
var (
|
||||
globalLogBuffer *LogBuffer
|
||||
logBufferOnce sync.Once
|
||||
|
||||
authorizationBearerPattern = regexp.MustCompile(`(?i)\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||
genericKeyValuePattern = regexp.MustCompile(`(?i)\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)`)
|
||||
queryTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+`)
|
||||
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||
)
|
||||
|
||||
// GetLogBuffer returns the singleton log buffer instance
|
||||
func sanitizeSensitiveLogText(message string) string {
|
||||
redacted := message
|
||||
redacted = authorizationBearerPattern.ReplaceAllString(redacted, "Authorization: Bearer [REDACTED]")
|
||||
redacted = genericKeyValuePattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`)
|
||||
redacted = queryTokenPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
|
||||
redacted = bearerTokenPattern.ReplaceAllString(redacted, "Bearer [REDACTED]")
|
||||
return redacted
|
||||
}
|
||||
|
||||
func GetLogBuffer() *LogBuffer {
|
||||
logBufferOnce.Do(func() {
|
||||
globalLogBuffer = &LogBuffer{
|
||||
entries: make([]LogEntry, 0, 1000),
|
||||
maxSize: 1000,
|
||||
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
||||
maxSize: defaultLogBufferSize,
|
||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||
}
|
||||
})
|
||||
return globalLogBuffer
|
||||
}
|
||||
|
||||
func truncateLogMessage(message string) string {
|
||||
runes := []rune(message)
|
||||
if len(runes) <= maxLogMessageLength {
|
||||
return message
|
||||
}
|
||||
return string(runes[:maxLogMessageLength]) + "...[truncated]"
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
lb.loggingEnabled = enabled
|
||||
}
|
||||
|
||||
// IsLoggingEnabled returns whether logging is enabled
|
||||
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
@@ -60,6 +86,9 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
return
|
||||
}
|
||||
|
||||
message = sanitizeSensitiveLogText(message)
|
||||
message = truncateLogMessage(message)
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now().Format("15:04:05.000"),
|
||||
Level: level,
|
||||
@@ -75,7 +104,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
fmt.Printf("[%s] %s\n", tag, message)
|
||||
}
|
||||
|
||||
// GetAll returns all log entries as JSON
|
||||
func (lb *LogBuffer) GetAll() string {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
@@ -99,21 +127,18 @@ func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||
return entries, len(lb.entries)
|
||||
}
|
||||
|
||||
// Clear clears all log entries
|
||||
func (lb *LogBuffer) Clear() {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
lb.entries = lb.entries[:0]
|
||||
}
|
||||
|
||||
// Count returns the number of log entries
|
||||
func (lb *LogBuffer) Count() int {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
return len(lb.entries)
|
||||
}
|
||||
|
||||
// Helper functions for logging with different levels
|
||||
func LogDebug(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
@@ -163,15 +188,10 @@ func GoLog(format string, args ...interface{}) {
|
||||
GetLogBuffer().Add(level, tag, message)
|
||||
}
|
||||
|
||||
// Exported functions for Flutter
|
||||
|
||||
// GetLogs returns all logs as JSON array
|
||||
func GetLogs() string {
|
||||
return GetLogBuffer().GetAll()
|
||||
}
|
||||
|
||||
// GetLogsSince returns logs since the given index
|
||||
// Returns JSON: {"logs": [...], "next_index": N}
|
||||
func GetLogsSince(index int) string {
|
||||
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||
logsJson, _ := json.Marshal(entries)
|
||||
@@ -179,17 +199,14 @@ func GetLogsSince(index int) string {
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearLogs clears all logs
|
||||
func ClearLogs() {
|
||||
GetLogBuffer().Clear()
|
||||
}
|
||||
|
||||
// GetLogCount returns the number of log entries
|
||||
func GetLogCount() int {
|
||||
return GetLogBuffer().Count()
|
||||
}
|
||||
|
||||
// SetLoggingEnabled enables or disables logging from Flutter
|
||||
func SetLoggingEnabled(enabled bool) {
|
||||
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||
}
|
||||
|
||||
+187
-38
@@ -4,16 +4,97 @@ import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
stdimage "image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-flac/flacpicture"
|
||||
"github.com/go-flac/flacvorbis"
|
||||
"github.com/go-flac/go-flac"
|
||||
"github.com/go-flac/flacpicture/v2"
|
||||
"github.com/go-flac/flacvorbis/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 {
|
||||
Title string
|
||||
Artist string
|
||||
@@ -29,6 +110,8 @@ type Metadata struct {
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
Composer string
|
||||
Comment string
|
||||
}
|
||||
|
||||
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
@@ -98,6 +181,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||
}
|
||||
|
||||
if metadata.Composer != "" {
|
||||
setComment(cmt, "COMPOSER", metadata.Composer)
|
||||
}
|
||||
|
||||
if metadata.Comment != "" {
|
||||
setComment(cmt, "COMMENT", metadata.Comment)
|
||||
}
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
@@ -117,19 +208,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
picture, err := flacpicture.NewFromImageData(
|
||||
flacpicture.PictureTypeFrontCover,
|
||||
"Front Cover",
|
||||
coverData,
|
||||
"image/jpeg",
|
||||
)
|
||||
picBlock, err := buildPictureBlock(coverPath, coverData)
|
||||
if err != nil {
|
||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
||||
} else {
|
||||
picBlock := picture.Marshal()
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||
return fmt.Errorf("failed to create picture block: %w", err)
|
||||
}
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
||||
@@ -206,6 +290,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||
}
|
||||
|
||||
if metadata.Composer != "" {
|
||||
setComment(cmt, "COMPOSER", metadata.Composer)
|
||||
}
|
||||
|
||||
if metadata.Comment != "" {
|
||||
setComment(cmt, "COMMENT", metadata.Comment)
|
||||
}
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
@@ -220,19 +312,12 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
}
|
||||
}
|
||||
|
||||
picture, err := flacpicture.NewFromImageData(
|
||||
flacpicture.PictureTypeFrontCover,
|
||||
"Front Cover",
|
||||
coverData,
|
||||
"image/jpeg",
|
||||
)
|
||||
picBlock, err := buildPictureBlock("", coverData)
|
||||
if err != nil {
|
||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
||||
} else {
|
||||
picBlock := picture.Marshal()
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||
return fmt.Errorf("failed to create picture block: %w", err)
|
||||
}
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
@@ -292,6 +377,12 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
metadata.Date = getComment(cmt, "YEAR")
|
||||
}
|
||||
|
||||
metadata.Genre = getComment(cmt, "GENRE")
|
||||
metadata.Label = getComment(cmt, "ORGANIZATION")
|
||||
metadata.Copyright = getComment(cmt, "COPYRIGHT")
|
||||
metadata.Composer = getComment(cmt, "COMPOSER")
|
||||
metadata.Comment = getComment(cmt, "COMMENT")
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -451,33 +542,91 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||
}
|
||||
|
||||
func ExtractLyrics(filePath string) (string, error) {
|
||||
lower := strings.ToLower(filePath)
|
||||
|
||||
if strings.HasSuffix(lower, ".flac") {
|
||||
return extractLyricsFromFlac(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".mp3") {
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err != nil || meta == nil {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||
return meta.Lyrics, nil
|
||||
}
|
||||
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||
return meta.Comment, nil
|
||||
}
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||
meta, err := ReadOggVorbisComments(filePath)
|
||||
if err != nil || meta == nil {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||
return meta.Lyrics, nil
|
||||
}
|
||||
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||
return meta.Comment, nil
|
||||
}
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unsupported file format for lyrics extraction")
|
||||
}
|
||||
|
||||
func extractLyricsFromFlac(filePath string) (string, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
for _, meta := range f.Meta {
|
||||
if meta.Type == flac.VorbisComment {
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if meta.Type != flac.VorbisComment {
|
||||
continue
|
||||
}
|
||||
|
||||
lyrics, err := cmt.Get("LYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
lyrics, err := cmt.Get("LYRICS")
|
||||
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
|
||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
func looksLikeEmbeddedLyrics(value string) bool {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.Contains(lower, "[ar:") || strings.Contains(lower, "[ti:") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(trimmed, "\n") && strings.Contains(trimmed, "[") && strings.Contains(trimmed, "]") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type AudioQuality struct {
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isFDOutput(outputFD int) bool {
|
||||
return outputFD > 0
|
||||
}
|
||||
|
||||
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
|
||||
if isFDOutput(outputFD) {
|
||||
return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil
|
||||
}
|
||||
return os.Create(outputPath)
|
||||
}
|
||||
|
||||
func cleanupOutputOnError(outputPath string, outputFD int) {
|
||||
if isFDOutput(outputFD) {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(outputPath)
|
||||
if path == "" || strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
+58
-8
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -9,7 +10,7 @@ import (
|
||||
type TrackIDCacheEntry struct {
|
||||
TidalTrackID int64
|
||||
QobuzTrackID int64
|
||||
AmazonTrackID string
|
||||
AmazonURL string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
@@ -106,7 +107,7 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -115,7 +116,7 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
entry = &TrackIDCacheEntry{}
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.AmazonTrackID = trackID
|
||||
entry.AmazonURL = amazonURL
|
||||
now := time.Now()
|
||||
entry.ExpiresAt = now.Add(c.ttl)
|
||||
|
||||
@@ -156,17 +157,20 @@ func FetchCoverAndLyricsParallel(
|
||||
) *ParallelDownloadResult {
|
||||
result := &ParallelDownloadResult{}
|
||||
var wg sync.WaitGroup
|
||||
var resultMu sync.Mutex
|
||||
|
||||
if coverURL != "" {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||
resultMu.Lock()
|
||||
if err != nil {
|
||||
result.CoverErr = err
|
||||
} else {
|
||||
result.CoverData = data
|
||||
}
|
||||
resultMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -177,6 +181,7 @@ func FetchCoverAndLyricsParallel(
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
resultMu.Lock()
|
||||
if err != nil {
|
||||
result.LyricsErr = err
|
||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
@@ -185,6 +190,7 @@ func FetchCoverAndLyricsParallel(
|
||||
} else {
|
||||
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||
}
|
||||
resultMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -211,6 +217,9 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, req := range requests {
|
||||
if req.ISRC == "" {
|
||||
continue
|
||||
}
|
||||
if cached := cache.Get(req.ISRC); cached != nil {
|
||||
continue
|
||||
}
|
||||
@@ -225,7 +234,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
case "tidal":
|
||||
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||
case "qobuz":
|
||||
preWarmQobuzCache(r.ISRC)
|
||||
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
||||
case "amazon":
|
||||
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
||||
}
|
||||
@@ -243,10 +252,30 @@ func preWarmTidalCache(isrc, _, _ string) {
|
||||
}
|
||||
}
|
||||
|
||||
func preWarmQobuzCache(isrc string) {
|
||||
// preWarmQobuzCache tries to get Qobuz Track ID in the following order:
|
||||
// 1. From SongLink (fast, no Qobuz API call needed)
|
||||
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
||||
func preWarmQobuzCache(isrc, spotifyID string) {
|
||||
// First, try to get QobuzID from SongLink - this is faster and more reliable
|
||||
if spotifyID != "" {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.QobuzID != "" {
|
||||
// Parse QobuzID to int64
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
||||
GetTrackIDCache().SetQobuz(isrc, trackID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Direct ISRC search on Qobuz API
|
||||
downloader := NewQobuzDownloader()
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
if err == nil && track != nil {
|
||||
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from direct ISRC search for %s\n", track.ID, isrc)
|
||||
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||
}
|
||||
}
|
||||
@@ -254,13 +283,34 @@ func preWarmQobuzCache(isrc string) {
|
||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.Amazon {
|
||||
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||
if err == nil && availability != nil && availability.AmazonURL != "" {
|
||||
GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
|
||||
}
|
||||
}
|
||||
|
||||
func PreWarmCache(tracksJSON string) error {
|
||||
var requests []PreWarmCacheRequest
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Service string `json:"service"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||
return fmt.Errorf("failed to parse tracks JSON: %w", err)
|
||||
}
|
||||
|
||||
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||
for i, t := range tracks {
|
||||
requests[i] = PreWarmCacheRequest{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
SpotifyID: t.SpotifyID,
|
||||
Service: t.Service,
|
||||
}
|
||||
}
|
||||
|
||||
go PreWarmTrackCache(requests)
|
||||
return nil
|
||||
|
||||
@@ -187,13 +187,13 @@ type ItemProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
itemID string
|
||||
current int64
|
||||
lastReported int64 // Track last reported bytes for threshold-based updates
|
||||
startTime time.Time // Track start time for speed calculation
|
||||
lastTime time.Time // Track last update time for speed calculation
|
||||
lastBytes int64 // Track bytes at last speed calculation
|
||||
lastReported int64
|
||||
startTime time.Time
|
||||
lastTime time.Time
|
||||
lastBytes int64
|
||||
}
|
||||
|
||||
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
||||
const progressUpdateThreshold = 64 * 1024
|
||||
|
||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||
now := time.Now()
|
||||
|
||||
+267
-101
@@ -380,10 +380,46 @@ func decodeXOR(data []byte) string {
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return "", fmt.Errorf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
if success, ok := raw["success"].(bool); ok && !success {
|
||||
if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
return "", fmt.Errorf("api returned success=false")
|
||||
}
|
||||
|
||||
if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
|
||||
return strings.TrimSpace(linkVal), nil
|
||||
}
|
||||
|
||||
if data, ok := raw["data"].(map[string]any); ok {
|
||||
if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
|
||||
return strings.TrimSpace(linkVal), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no download URL in response")
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
||||
formatID := mapJumoQuality(quality)
|
||||
region := "US"
|
||||
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
||||
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
||||
|
||||
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
||||
|
||||
@@ -392,6 +428,8 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
@@ -725,75 +763,151 @@ type qobuzAPIResult struct {
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// Qobuz API timeout configuration
|
||||
// Mobile networks are more unstable, so we use longer timeouts
|
||||
const (
|
||||
qobuzAPITimeoutMobile = 25 * time.Second
|
||||
qobuzMaxRetries = 2 // Number of retries per API
|
||||
qobuzRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// getQobuzAPITimeout returns appropriate timeout based on platform
|
||||
// For mobile (gomobile builds), we use longer timeouts
|
||||
func getQobuzAPITimeout() time.Duration {
|
||||
// Since this runs in gomobile context, we always use mobile timeout
|
||||
// The Go backend is only used on mobile (Android/iOS)
|
||||
return qobuzAPITimeoutMobile
|
||||
}
|
||||
|
||||
// qobuzSquidCountries defines the region fallback order for squid.wtf API
|
||||
var qobuzSquidCountries = []string{"US", "FR"}
|
||||
|
||||
// fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic
|
||||
// For squid.wtf APIs, it tries US region first, then falls back to FR
|
||||
func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) {
|
||||
isSquid := strings.Contains(api, "squid.wtf")
|
||||
|
||||
if isSquid {
|
||||
for _, country := range qobuzSquidCountries {
|
||||
GoLog("[Qobuz] Trying squid.wtf with country=%s\n", country)
|
||||
result, err := fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, country)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
GoLog("[Qobuz] squid.wtf country=%s failed: %v\n", country, err)
|
||||
}
|
||||
return "", fmt.Errorf("squid.wtf failed for all regions (US, FR)")
|
||||
}
|
||||
|
||||
return fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, "")
|
||||
}
|
||||
|
||||
// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination
|
||||
func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeout time.Duration, country string) (string, error) {
|
||||
var lastErr error
|
||||
retryDelay := qobuzRetryDelay
|
||||
|
||||
for attempt := 0; attempt <= qobuzMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, api, retryDelay)
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay *= 2 // Exponential backoff
|
||||
}
|
||||
|
||||
client := NewHTTPClientWithTimeout(timeout)
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||
if country != "" {
|
||||
reqURL += "&country=" + country
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Check for retryable errors (timeout, connection reset)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "eof") {
|
||||
continue // Retry
|
||||
}
|
||||
break // Non-retryable error
|
||||
}
|
||||
// Server errors are retryable
|
||||
if resp.StatusCode >= 500 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
// 429 rate limit - wait and retry
|
||||
if resp.StatusCode == 429 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("rate limited")
|
||||
retryDelay = 2 * time.Second // Wait longer for rate limit
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if len(body) > 0 && body[0] == '<' {
|
||||
return "", fmt.Errorf("received HTML instead of JSON")
|
||||
}
|
||||
|
||||
urlVal, parseErr := extractQobuzDownloadURLFromBody(body)
|
||||
if parseErr == nil {
|
||||
return urlVal, nil
|
||||
}
|
||||
lastErr = parseErr
|
||||
continue
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
return "", fmt.Errorf("all retries failed")
|
||||
}
|
||||
|
||||
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
|
||||
|
||||
resultChan := make(chan qobuzAPIResult, len(apis))
|
||||
startTime := time.Now()
|
||||
timeout := getQobuzAPITimeout()
|
||||
|
||||
for _, apiURL := range apis {
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
|
||||
client := NewHTTPClientWithTimeout(15 * time.Second)
|
||||
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
downloadURL, err := fetchQobuzURLWithRetry(api, trackID, quality, timeout)
|
||||
resultChan <- qobuzAPIResult{
|
||||
apiURL: api,
|
||||
downloadURL: downloadURL,
|
||||
err: err,
|
||||
duration: time.Since(reqStart),
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
if len(body) > 0 && body[0] == '<' {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var errorResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("invalid JSON: %v", err), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
if result.URL != "" {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, downloadURL: result.URL, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("no download URL in response"), duration: time.Since(reqStart)}
|
||||
}(apiURL)
|
||||
}
|
||||
|
||||
@@ -860,7 +974,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if itemID != "" {
|
||||
@@ -897,7 +1011,7 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -916,23 +1030,23 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
@@ -950,13 +1064,17 @@ type QobuzDownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
@@ -964,6 +1082,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
var track *QobuzTrack
|
||||
var err error
|
||||
|
||||
// Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate)
|
||||
if req.QobuzID != "" {
|
||||
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
|
||||
var trackID int64
|
||||
@@ -978,17 +1097,43 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Use cached Qobuz Track ID (fast, no search needed)
|
||||
if track == nil && req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||
track, err = downloader.GetTrackByID(cached.QobuzTrackID)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||
GoLog("[Qobuz] Cache hit but GetTrackByID failed: %v\n", err)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID
|
||||
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
|
||||
GoLog("[Qobuz] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||
songLinkClient := NewSongLinkClient()
|
||||
availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
if slErr == nil && availability != nil && availability.QobuzID != "" {
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Qobuz] Got Qobuz ID %d from SongLink\n", trackID)
|
||||
track, err = downloader.GetTrackByID(trackID)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Failed to get track by SongLink ID %d: %v\n", trackID, err)
|
||||
track = nil
|
||||
} else if track != nil {
|
||||
GoLog("[Qobuz] Successfully found track via SongLink ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
|
||||
// Cache for future use
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: ISRC search with duration verification
|
||||
if track == nil && req.ISRC != "" {
|
||||
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||
@@ -1005,7 +1150,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
|
||||
if track == nil {
|
||||
GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
@@ -1035,11 +1182,18 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
qobuzQuality := "27"
|
||||
@@ -1077,7 +1231,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
)
|
||||
}()
|
||||
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return QobuzDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
@@ -1122,39 +1276,50 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
if isSafOutput {
|
||||
GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
} else {
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||
}
|
||||
}
|
||||
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
|
||||
return QobuzDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: actualBitDepth,
|
||||
@@ -1166,5 +1331,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||
t.Run("reads nested data.url", func(t *testing.T) {
|
||||
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
|
||||
|
||||
got, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got != "https://example.test/audio.flac" {
|
||||
t.Fatalf("unexpected URL: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reads top-level url", func(t *testing.T) {
|
||||
body := []byte(`{"url":"https://example.test/top.flac"}`)
|
||||
|
||||
got, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got != "https://example.test/top.flac" {
|
||||
t.Fatalf("unexpected URL: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns API error", func(t *testing.T) {
|
||||
body := []byte(`{"error":"track not found"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "track not found" {
|
||||
t.Fatalf("expected track-not-found error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns message when success false", func(t *testing.T) {
|
||||
body := []byte(`{"success":false,"message":"blocked"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "blocked" {
|
||||
t.Fatalf("expected blocked error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+333
-46
@@ -8,7 +8,6 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SongLinkClient struct {
|
||||
@@ -16,16 +15,21 @@ type SongLinkClient struct {
|
||||
}
|
||||
|
||||
type TrackAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
Amazon bool `json:"amazon"`
|
||||
Qobuz bool `json:"qobuz"`
|
||||
Deezer bool `json:"deezer"`
|
||||
TidalURL string `json:"tidal_url,omitempty"`
|
||||
AmazonURL string `json:"amazon_url,omitempty"`
|
||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||
DeezerURL string `json:"deezer_url,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
Amazon bool `json:"amazon"`
|
||||
Qobuz bool `json:"qobuz"`
|
||||
Deezer bool `json:"deezer"`
|
||||
YouTube bool `json:"youtube"`
|
||||
TidalURL string `json:"tidal_url,omitempty"`
|
||||
AmazonURL string `json:"amazon_url,omitempty"`
|
||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||
DeezerURL string `json:"deezer_url,omitempty"`
|
||||
YouTubeURL string `json:"youtube_url,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
YouTubeID string `json:"youtube_id,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -36,7 +40,7 @@ var (
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
songLinkClientOnce.Do(func() {
|
||||
globalSongLinkClient = &SongLinkClient{
|
||||
client: NewHTTPClientWithTimeout(SongLinkTimeout),
|
||||
client: NewMetadataHTTPClient(SongLinkTimeout),
|
||||
}
|
||||
})
|
||||
return globalSongLinkClient
|
||||
@@ -98,6 +102,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
@@ -111,6 +116,28 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
|
||||
// Fallback to regular youtube if youtubeMusic not available
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
@@ -131,40 +158,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
func checkQobuzAvailability(isrc string) bool {
|
||||
client := NewHTTPClientWithTimeout(10 * time.Second)
|
||||
appID := "798273057"
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return false
|
||||
}
|
||||
|
||||
var searchResp struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return searchResp.Tracks.Total > 0
|
||||
}
|
||||
|
||||
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||
func extractDeezerIDFromURL(deezerURL string) string {
|
||||
parts := strings.Split(deezerURL, "/")
|
||||
@@ -178,6 +171,148 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractQobuzIDFromURL extracts Qobuz track ID from URL
|
||||
// URL formats:
|
||||
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
||||
// - https://open.qobuz.com/track/12345678
|
||||
// - https://www.qobuz.com/track/12345678
|
||||
// - https://play.qobuz.com/track/12345678
|
||||
func extractQobuzIDFromURL(qobuzURL string) string {
|
||||
if qobuzURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try to find /track/ID pattern first
|
||||
if strings.Contains(qobuzURL, "/track/") {
|
||||
parts := strings.Split(qobuzURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
// Remove query parameters
|
||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
// Remove trailing slash or path
|
||||
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
// Validate it's a number
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from album URL with track highlight
|
||||
// Format: /album/albumname/trackid or ?trackId=12345678
|
||||
if strings.Contains(qobuzURL, "trackId=") {
|
||||
parts := strings.Split(qobuzURL, "trackId=")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
if idx := strings.Index(idPart, "&"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: get last numeric segment from URL
|
||||
parts := strings.Split(qobuzURL, "/")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := parts[i]
|
||||
// Remove query parameters
|
||||
if idx := strings.Index(part, "?"); idx > 0 {
|
||||
part = part[:idx]
|
||||
}
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" && isNumeric(part) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractTidalIDFromURL extracts Tidal track ID from URL
|
||||
// URL formats:
|
||||
// - https://tidal.com/browse/track/12345678
|
||||
// - https://listen.tidal.com/track/12345678
|
||||
func extractTidalIDFromURL(tidalURL string) string {
|
||||
if tidalURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.Contains(tidalURL, "/track/") {
|
||||
parts := strings.Split(tidalURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractYouTubeIDFromURL extracts YouTube video ID from URL
|
||||
// URL formats:
|
||||
// - https://www.youtube.com/watch?v=VIDEO_ID
|
||||
// - https://youtu.be/VIDEO_ID
|
||||
// - https://music.youtube.com/watch?v=VIDEO_ID
|
||||
func extractYouTubeIDFromURL(youtubeURL string) string {
|
||||
if youtubeURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Handle youtu.be short URLs
|
||||
if strings.Contains(youtubeURL, "youtu.be/") {
|
||||
parts := strings.Split(youtubeURL, "youtu.be/")
|
||||
if len(parts) >= 2 {
|
||||
idPart := parts[1]
|
||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
if idx := strings.Index(idPart, "&"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
return strings.TrimSpace(idPart)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle youtube.com URLs with ?v= parameter
|
||||
parsed, err := url.Parse(youtubeURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if v := parsed.Query().Get("v"); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
// Handle /embed/ format
|
||||
if strings.Contains(parsed.Path, "/embed/") {
|
||||
parts := strings.Split(parsed.Path, "/embed/")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Split(parts[1], "/")[0]
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isNumeric is defined in library_scan.go
|
||||
|
||||
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
@@ -191,6 +326,20 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
|
||||
return availability.DeezerID, nil
|
||||
}
|
||||
|
||||
// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink
|
||||
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !availability.YouTube || availability.YouTubeURL == "" {
|
||||
return "", fmt.Errorf("track not found on YouTube")
|
||||
}
|
||||
|
||||
return availability.YouTubeURL, nil
|
||||
}
|
||||
|
||||
// AlbumAvailability represents album availability on different platforms
|
||||
type AlbumAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
@@ -353,6 +502,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
@@ -360,10 +510,29 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
}
|
||||
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
@@ -431,6 +600,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
@@ -438,12 +608,31 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
@@ -499,3 +688,101 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
|
||||
|
||||
return availability.AmazonURL, nil
|
||||
}
|
||||
|
||||
// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink
|
||||
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
|
||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !availability.YouTube || availability.YouTubeURL == "" {
|
||||
return "", fmt.Errorf("track not found on YouTube")
|
||||
}
|
||||
|
||||
return availability.YouTubeURL, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(inputURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 || resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on SongLink")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
EntityID string `json:"entityUniqueId"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{}
|
||||
|
||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+11
-14
@@ -63,7 +63,6 @@ var (
|
||||
credentialsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
|
||||
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
||||
|
||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
@@ -115,7 +114,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
c := &SpotifyMetadataClient{
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
rng: rand.New(src),
|
||||
@@ -141,7 +140,7 @@ type TrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumTrackMetadata struct {
|
||||
@@ -210,7 +209,7 @@ type ArtistAlbumMetadata struct {
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
Images string `json:"images"`
|
||||
AlbumType string `json:"album_type"` // album, single, compilation
|
||||
AlbumType string `json:"album_type"`
|
||||
Artists string `json:"artists"`
|
||||
}
|
||||
|
||||
@@ -532,7 +531,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
|
||||
albumImage := firstImageURL(data.Images)
|
||||
|
||||
// Get first artist ID
|
||||
var firstArtistId string
|
||||
if len(data.Artists) > 0 {
|
||||
firstArtistId = data.Artists[0].ID
|
||||
@@ -565,7 +563,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
|
||||
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
||||
|
||||
// Collect track IDs for parallel ISRC fetching
|
||||
trackIDs := make([]string, len(allTrackItems))
|
||||
for i, item := range allTrackItems {
|
||||
trackIDs[i] = item.ID
|
||||
@@ -937,14 +934,14 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
||||
defer c.rngMu.Unlock()
|
||||
|
||||
macMajor := c.rng.Intn(4) + 11
|
||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||
macMinor := c.rng.Intn(5) + 4
|
||||
webkitMajor := c.rng.Intn(7) + 530
|
||||
webkitMinor := c.rng.Intn(7) + 30
|
||||
chromeMajor := c.rng.Intn(25) + 80
|
||||
chromeBuild := c.rng.Intn(1500) + 3000
|
||||
chromePatch := c.rng.Intn(65) + 60
|
||||
safariMajor := c.rng.Intn(7) + 530
|
||||
safariMinor := c.rng.Intn(6) + 30
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
|
||||
+326
-166
@@ -317,7 +317,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find exact ISRC match
|
||||
for i := range result.Items {
|
||||
if result.Items[i].ISRC == isrc {
|
||||
return &result.Items[i], nil
|
||||
@@ -341,7 +340,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
// Build search queries - multiple strategies (same as PC version)
|
||||
queries := []string{}
|
||||
|
||||
// Strategy 1: Artist + Track name (original)
|
||||
if artistName != "" && trackName != "" {
|
||||
queries = append(queries, artistName+" "+trackName)
|
||||
}
|
||||
@@ -584,13 +582,123 @@ type tidalAPIResult struct {
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||
// Tidal API timeout configuration
|
||||
// Mobile networks are more unstable, so we use longer timeouts
|
||||
const (
|
||||
tidalAPITimeoutMobile = 25 * time.Second
|
||||
tidalMaxRetries = 2 // Number of retries per API
|
||||
tidalRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// fetchTidalURLWithRetry fetches download URL from a single Tidal API with retry logic
|
||||
func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) {
|
||||
var lastErr error
|
||||
retryDelay := tidalRetryDelay
|
||||
|
||||
for attempt := 0; attempt <= tidalMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay)
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay *= 2 // Exponential backoff
|
||||
}
|
||||
|
||||
client := NewHTTPClientWithTimeout(timeout)
|
||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Check for retryable errors (timeout, connection reset)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "eof") {
|
||||
continue // Retry
|
||||
}
|
||||
break // Non-retryable error
|
||||
}
|
||||
// Server errors are retryable
|
||||
if resp.StatusCode >= 500 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
// 429 rate limit - wait and retry
|
||||
if resp.StatusCode == 429 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("rate limited")
|
||||
retryDelay = 2 * time.Second // Wait longer for rate limit
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return TidalDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// Try V2 response format (with manifest)
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
return TidalDownloadInfo{}, fmt.Errorf("returned PREVIEW instead of FULL")
|
||||
}
|
||||
|
||||
return TidalDownloadInfo{
|
||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||
BitDepth: v2Response.Data.BitDepth,
|
||||
SampleRate: v2Response.Data.SampleRate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Try V1 response format
|
||||
var v1Responses []struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
return TidalDownloadInfo{
|
||||
URL: item.OriginalTrackURL,
|
||||
BitDepth: 16,
|
||||
SampleRate: 44100,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TidalDownloadInfo{}, fmt.Errorf("no download URL or manifest in response")
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return TidalDownloadInfo{}, lastErr
|
||||
}
|
||||
return TidalDownloadInfo{}, fmt.Errorf("all retries failed")
|
||||
}
|
||||
|
||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||
GoLog("[Tidal] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
|
||||
|
||||
resultChan := make(chan tidalAPIResult, len(apis))
|
||||
startTime := time.Now()
|
||||
@@ -598,69 +706,13 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
for _, apiURL := range apis {
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
|
||||
client := NewHTTPClientWithTimeout(15 * time.Second)
|
||||
|
||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
info, err := fetchTidalURLWithRetry(api, trackID, quality, tidalAPITimeoutMobile)
|
||||
resultChan <- tidalAPIResult{
|
||||
apiURL: api,
|
||||
info: info,
|
||||
err: err,
|
||||
duration: time.Since(reqStart),
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
info := TidalDownloadInfo{
|
||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||
BitDepth: v2Response.Data.BitDepth,
|
||||
SampleRate: v2Response.Data.SampleRate,
|
||||
}
|
||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var v1Responses []struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
info := TidalDownloadInfo{
|
||||
URL: item.OriginalTrackURL,
|
||||
BitDepth: 16,
|
||||
SampleRate: 44100,
|
||||
}
|
||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)}
|
||||
}(apiURL)
|
||||
}
|
||||
|
||||
@@ -787,6 +839,10 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
|
||||
}
|
||||
|
||||
if segmentCount == 0 {
|
||||
return "", "", nil, fmt.Errorf("no segments found in manifest")
|
||||
}
|
||||
|
||||
for i := 1; i <= segmentCount; i++ {
|
||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||
mediaURLs = append(mediaURLs, mediaURL)
|
||||
@@ -795,7 +851,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
return "", initURL, mediaURLs, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||
@@ -808,7 +864,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, outputFD, itemID)
|
||||
}
|
||||
|
||||
if itemID != "" {
|
||||
@@ -845,7 +901,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -864,30 +920,30 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error {
|
||||
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath string, outputFD int, itemID string) error {
|
||||
fmt.Println("[Tidal] Parsing manifest...")
|
||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||
if err != nil {
|
||||
@@ -932,7 +988,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
@@ -948,19 +1004,19 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
@@ -968,17 +1024,20 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
}
|
||||
|
||||
// For DASH format, determine correct M4A path
|
||||
// If outputPath already ends with .m4a, use it directly
|
||||
// Otherwise, convert .flac to .m4a
|
||||
// If outputPath already ends with .m4a, use it directly.
|
||||
// If outputPath ends with .flac, convert .flac to .m4a.
|
||||
// Otherwise (e.g., SAF /proc/self/fd/*), use outputPath as-is.
|
||||
var m4aPath string
|
||||
if strings.HasSuffix(outputPath, ".m4a") {
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
} else if strings.HasSuffix(outputPath, ".flac") {
|
||||
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
} else {
|
||||
m4aPath = outputPath
|
||||
}
|
||||
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
||||
|
||||
out, err := os.Create(m4aPath)
|
||||
out, err := openOutputForWrite(m4aPath, outputFD)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
|
||||
return fmt.Errorf("failed to create M4A file: %w", err)
|
||||
@@ -987,20 +1046,20 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
GoLog("[Tidal] Downloading init segment...\n")
|
||||
if isDownloadCancelled(itemID) {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Init segment request failed: %v\n", err)
|
||||
return fmt.Errorf("failed to create init segment request: %w", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1010,7 +1069,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
|
||||
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
@@ -1018,7 +1077,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1030,7 +1089,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
for i, mediaURL := range mediaURLs {
|
||||
if isDownloadCancelled(itemID) {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
@@ -1046,14 +1105,14 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err)
|
||||
return fmt.Errorf("failed to create segment %d request: %w", i+1, err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1063,7 +1122,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
|
||||
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
||||
}
|
||||
@@ -1071,7 +1130,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1081,7 +1140,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Failed to close M4A file: %v\n", err)
|
||||
return fmt.Errorf("failed to close M4A file: %w", err)
|
||||
}
|
||||
@@ -1350,8 +1409,11 @@ func isLatinScript(s string) bool {
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
@@ -1407,49 +1469,83 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
|
||||
if track == nil && req.SpotifyID != "" {
|
||||
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||
var tidalURL string
|
||||
var slErr error
|
||||
|
||||
var trackID int64
|
||||
var gotTidalID bool
|
||||
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
tidalURL, slErr = songlink.GetTidalURLFromDeezer(deezerID)
|
||||
availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
if slErr == nil && availability != nil && availability.TidalID != "" {
|
||||
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
// Fallback to URL parsing if TidalID not in struct
|
||||
if !gotTidalID && availability != nil && availability.TidalURL != "" {
|
||||
var idErr error
|
||||
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
|
||||
if idErr == nil && trackID > 0 {
|
||||
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID)
|
||||
songlink := NewSongLinkClient()
|
||||
availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
if slErr == nil && availability != nil && availability.TidalID != "" {
|
||||
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
// Fallback to URL parsing if TidalID not in struct
|
||||
if !gotTidalID && availability != nil && availability.TidalURL != "" {
|
||||
var idErr error
|
||||
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
|
||||
if idErr == nil && trackID > 0 {
|
||||
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if slErr == nil && tidalURL != "" {
|
||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||
if idErr == nil {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
if gotTidalID && trackID > 0 {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
|
||||
if track != nil && expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff > 3 {
|
||||
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||
expectedDurationSec, track.Duration)
|
||||
track = nil // Reject this match
|
||||
}
|
||||
if track != nil && expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff > 3 {
|
||||
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||
expectedDurationSec, track.Duration)
|
||||
track = nil // Reject this match
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for future use
|
||||
if track != nil && req.ISRC != "" {
|
||||
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1516,31 +1612,52 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
|
||||
var outputPath string
|
||||
var m4aPath string
|
||||
if quality == "HIGH" {
|
||||
filename = sanitizeFilename(filename) + ".m4a"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
outputExt := strings.TrimSpace(req.OutputExt)
|
||||
if outputExt == "" {
|
||||
if quality == "HIGH" {
|
||||
outputExt = ".m4a"
|
||||
} else {
|
||||
outputExt = ".flac"
|
||||
}
|
||||
} else if !strings.HasPrefix(outputExt, ".") {
|
||||
outputExt = "." + outputExt
|
||||
}
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
if quality != "HIGH" {
|
||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
var outputPath string
|
||||
var m4aPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
if outputExt == ".m4a" || quality == "HIGH" {
|
||||
filename = sanitizeFilename(filename) + ".m4a"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
}
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
if quality != "HIGH" {
|
||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tmpPath := outputPath + ".m4a.tmp"
|
||||
if _, err := os.Stat(tmpPath); err == nil {
|
||||
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||
os.Remove(tmpPath)
|
||||
if !isSafOutput {
|
||||
tmpPath := outputPath + ".m4a.tmp"
|
||||
if _, err := os.Stat(tmpPath); err == nil {
|
||||
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||
os.Remove(tmpPath)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Tidal] Using quality: %s\n", quality)
|
||||
@@ -1575,7 +1692,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
return "Direct URL"
|
||||
}())
|
||||
|
||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return TidalDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
@@ -1592,11 +1709,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
|
||||
actualOutputPath := outputPath
|
||||
if _, err := os.Stat(m4aPath); err == nil {
|
||||
actualOutputPath = m4aPath
|
||||
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||
} else if _, err := os.Stat(outputPath); err != nil {
|
||||
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||
if !isSafOutput {
|
||||
if _, err := os.Stat(m4aPath); err == nil {
|
||||
actualOutputPath = m4aPath
|
||||
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||
} else if _, err := os.Stat(outputPath); err != nil {
|
||||
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||
}
|
||||
}
|
||||
|
||||
releaseDate := req.ReleaseDate
|
||||
@@ -1635,7 +1754,15 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if strings.HasSuffix(actualOutputPath, ".flac") {
|
||||
actualExt := outputExt
|
||||
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
|
||||
actualExt = ".m4a"
|
||||
}
|
||||
if actualExt == "" && !isSafOutput {
|
||||
actualExt = strings.ToLower(filepath.Ext(actualOutputPath))
|
||||
}
|
||||
|
||||
if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) {
|
||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
@@ -1646,7 +1773,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
||||
GoLog("[Tidal] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
@@ -1666,7 +1793,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||
}
|
||||
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
||||
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
|
||||
if quality == "HIGH" {
|
||||
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
|
||||
|
||||
@@ -1676,7 +1803,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
||||
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
@@ -1690,7 +1817,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
}
|
||||
|
||||
bitDepth := downloadInfo.BitDepth
|
||||
sampleRate := downloadInfo.SampleRate
|
||||
@@ -1698,15 +1827,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
if quality == "HIGH" {
|
||||
bitDepth = 0
|
||||
sampleRate = 44100
|
||||
if parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return TidalDownloadResult{
|
||||
@@ -1723,3 +1846,40 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseTidalURL(input string) (string, string, error) {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
return "", "", fmt.Errorf("empty URL")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if parsed.Host != "tidal.com" && parsed.Host != "listen.tidal.com" && parsed.Host != "www.tidal.com" {
|
||||
return "", "", fmt.Errorf("not a Tidal URL")
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
|
||||
// Handle /browse/track/123 format
|
||||
if len(parts) > 0 && parts[0] == "browse" {
|
||||
parts = parts[1:]
|
||||
}
|
||||
|
||||
if len(parts) < 2 {
|
||||
return "", "", fmt.Errorf("invalid Tidal URL format")
|
||||
}
|
||||
|
||||
resourceType := parts[0]
|
||||
resourceID := parts[1]
|
||||
|
||||
switch resourceType {
|
||||
case "track", "album", "artist", "playlist":
|
||||
return resourceType, resourceID, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type YouTubeDownloader struct {
|
||||
client *http.Client
|
||||
apiURL string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var (
|
||||
globalYouTubeDownloader *YouTubeDownloader
|
||||
youtubeDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
type YouTubeQuality string
|
||||
|
||||
const (
|
||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
||||
)
|
||||
|
||||
type CobaltRequest struct {
|
||||
URL string `json:"url"`
|
||||
AudioBitrate string `json:"audioBitrate,omitempty"`
|
||||
AudioFormat string `json:"audioFormat,omitempty"`
|
||||
DownloadMode string `json:"downloadMode,omitempty"`
|
||||
FilenameStyle string `json:"filenameStyle,omitempty"`
|
||||
DisableMetadata bool `json:"disableMetadata,omitempty"`
|
||||
}
|
||||
|
||||
type CobaltResponse struct {
|
||||
Status string `json:"status"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Context *struct {
|
||||
Service string `json:"service,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
} `json:"context,omitempty"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type YouTubeDownloadResult struct {
|
||||
FilePath string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
Format string // "opus" or "mp3"
|
||||
Bitrate int
|
||||
LyricsLRC string
|
||||
CoverData []byte
|
||||
}
|
||||
|
||||
func NewYouTubeDownloader() *YouTubeDownloader {
|
||||
youtubeDownloaderOnce.Do(func() {
|
||||
globalYouTubeDownloader = &YouTubeDownloader{
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
||||
apiURL: "https://api.qwkuns.me",
|
||||
}
|
||||
})
|
||||
return globalYouTubeDownloader
|
||||
}
|
||||
|
||||
// SearchYouTube returns a YouTube Music search URL for the given track
|
||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||
searchQuery := url.QueryEscape(query)
|
||||
|
||||
GoLog("[YouTube] Search query: %s\n", query)
|
||||
|
||||
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
|
||||
|
||||
return youtubeMusicURL, nil
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
|
||||
var audioFormat string
|
||||
var audioBitrate string
|
||||
|
||||
switch quality {
|
||||
case YouTubeQualityOpus256:
|
||||
audioFormat = "opus"
|
||||
audioBitrate = "256"
|
||||
case YouTubeQualityMP3320:
|
||||
audioFormat = "mp3"
|
||||
audioBitrate = "320"
|
||||
default:
|
||||
audioFormat = "mp3"
|
||||
audioBitrate = "320"
|
||||
}
|
||||
|
||||
// Try SpotubeDL first (primary)
|
||||
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
||||
if extractErr == nil {
|
||||
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
||||
videoID, audioFormat, audioBitrate)
|
||||
|
||||
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
||||
} else {
|
||||
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
||||
}
|
||||
|
||||
// Fallback: direct Cobalt API (api.qwkuns.me)
|
||||
cobaltURL := toYouTubeMusicURL(youtubeURL)
|
||||
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
|
||||
cobaltURL, audioFormat, audioBitrate)
|
||||
|
||||
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// requestCobaltDirect sends a download request to the primary Cobalt API.
|
||||
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||
reqBody := CobaltRequest{
|
||||
URL: videoURL,
|
||||
AudioFormat: audioFormat,
|
||||
AudioBitrate: audioBitrate,
|
||||
DownloadMode: "audio",
|
||||
FilenameStyle: "basic",
|
||||
DisableMetadata: true,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cobalt API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var cobaltResp CobaltResponse
|
||||
if err := json.Unmarshal(body, &cobaltResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
|
||||
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
|
||||
}
|
||||
|
||||
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
|
||||
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
|
||||
}
|
||||
|
||||
if cobaltResp.URL == "" {
|
||||
return nil, fmt.Errorf("no download URL in response")
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
|
||||
return &cobaltResp, nil
|
||||
}
|
||||
|
||||
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
||||
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||
apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s",
|
||||
videoID, audioFormat, audioBitrate)
|
||||
|
||||
GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL)
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spotubedl request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
||||
}
|
||||
|
||||
if result.URL == "" {
|
||||
return nil, fmt.Errorf("no download URL from spotubedl")
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from SpotubeDL\n")
|
||||
return &CobaltResponse{
|
||||
Status: "tunnel",
|
||||
URL: result.URL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Download completed: %d bytes written\n", written)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func BuildYouTubeSearchURL(trackName, artistName string) string {
|
||||
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
|
||||
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
|
||||
}
|
||||
|
||||
func BuildYouTubeWatchURL(videoID string) string {
|
||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||
}
|
||||
|
||||
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
|
||||
func isYouTubeVideoID(s string) bool {
|
||||
if len(s) != 11 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func IsYouTubeURL(urlStr string) bool {
|
||||
lower := strings.ToLower(urlStr)
|
||||
return strings.Contains(lower, "youtube.com") ||
|
||||
strings.Contains(lower, "youtu.be") ||
|
||||
strings.Contains(lower, "music.youtube.com")
|
||||
}
|
||||
|
||||
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
|
||||
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
|
||||
func toYouTubeMusicURL(rawURL string) string {
|
||||
videoID, err := ExtractYouTubeVideoID(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||
}
|
||||
|
||||
func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
||||
if strings.Contains(urlStr, "youtu.be/") {
|
||||
parts := strings.Split(urlStr, "youtu.be/")
|
||||
if len(parts) >= 2 {
|
||||
videoID := strings.Split(parts[1], "?")[0]
|
||||
videoID = strings.Split(videoID, "&")[0]
|
||||
return strings.TrimSpace(videoID), nil
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// /watch?v=
|
||||
if v := parsed.Query().Get("v"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// /embed/
|
||||
if strings.Contains(parsed.Path, "/embed/") {
|
||||
parts := strings.Split(parsed.Path, "/embed/")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Split(parts[1], "/")[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
// /v/
|
||||
if strings.Contains(parsed.Path, "/v/") {
|
||||
parts := strings.Split(parsed.Path, "/v/")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Split(parts[1], "/")[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not extract video ID from URL")
|
||||
}
|
||||
|
||||
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||
downloader := NewYouTubeDownloader()
|
||||
|
||||
var quality YouTubeQuality
|
||||
switch strings.ToLower(req.Quality) {
|
||||
case "opus_256", "opus256", "opus":
|
||||
quality = YouTubeQualityOpus256
|
||||
case "mp3_320", "mp3320", "mp3":
|
||||
quality = YouTubeQualityMP3320
|
||||
default:
|
||||
quality = YouTubeQualityMP3320 // Default to MP3 320kbps
|
||||
}
|
||||
|
||||
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
|
||||
var youtubeURL string
|
||||
var lookupErr error
|
||||
|
||||
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
|
||||
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
|
||||
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
|
||||
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
||||
}
|
||||
|
||||
// Try Spotify ID via SongLink
|
||||
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||
songlink := NewSongLinkClient()
|
||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
|
||||
if lookupErr != nil {
|
||||
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
|
||||
} else {
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Try Deezer ID via SongLink
|
||||
if youtubeURL == "" && req.DeezerID != "" {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
|
||||
if lookupErr != nil {
|
||||
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
|
||||
} else {
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Try ISRC via SongLink
|
||||
if youtubeURL == "" && req.ISRC != "" {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
||||
songlink := NewSongLinkClient()
|
||||
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
|
||||
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
|
||||
youtubeURL = availability.YouTubeURL
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
|
||||
} else if isrcErr != nil {
|
||||
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Cobalt requires direct video URLs, not search URLs
|
||||
if youtubeURL == "" {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
|
||||
|
||||
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
|
||||
if err != nil {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
var ext string
|
||||
var format string
|
||||
var bitrate int
|
||||
switch quality {
|
||||
case YouTubeQualityOpus256:
|
||||
ext = ".opus"
|
||||
format = "opus"
|
||||
bitrate = 256
|
||||
case YouTubeQualityMP3320:
|
||||
ext = ".mp3"
|
||||
format = "mp3"
|
||||
bitrate = 320
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ext
|
||||
|
||||
var outputPath string
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
outputPath = req.OutputDir + "/" + filename
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
||||
|
||||
// Parallel fetch cover art + lyrics
|
||||
var parallelResult *ParallelDownloadResult
|
||||
if req.EmbedLyrics || req.CoverURL != "" {
|
||||
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
req.CoverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.EmbedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}
|
||||
|
||||
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
var coverData []byte
|
||||
if parallelResult != nil {
|
||||
if parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
|
||||
}
|
||||
if parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
|
||||
}
|
||||
}
|
||||
|
||||
return YouTubeDownloadResult{
|
||||
FilePath: outputPath,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Format: format,
|
||||
Bitrate: bitrate,
|
||||
LyricsLRC: lyricsLRC,
|
||||
CoverData: coverData,
|
||||
}, nil
|
||||
}
|
||||
@@ -217,6 +217,14 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "editFileMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
let metadataJson = args["metadata_json"] as? String ?? "{}"
|
||||
let response = GobackendEditFileMetadata(filePath, metadataJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchDeezerAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
@@ -242,6 +250,20 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "parseTidalUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendParseTidalURLExport(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "convertTidalToSpotifyDeezer":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendConvertTidalToSpotifyDeezer(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchDeezerByISRC":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let isrc = args["isrc"] as! String
|
||||
@@ -625,6 +647,14 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "runPostProcessingV2":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let inputJson = args["input"] as? String ?? ""
|
||||
let metadataJson = args["metadata"] as? String ?? ""
|
||||
let response = GobackendRunPostProcessingV2JSON(inputJson, metadataJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getPostProcessingProviders":
|
||||
let response = GobackendGetPostProcessingProvidersJSON(&error)
|
||||
@@ -687,6 +717,43 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Local Library Scanning
|
||||
case "setLibraryCoverCacheDir":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let cacheDir = args["cache_dir"] as! String
|
||||
GobackendSetLibraryCoverCacheDirJSON(cacheDir)
|
||||
return nil
|
||||
|
||||
case "scanLibraryFolder":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let folderPath = args["folder_path"] as! String
|
||||
let response = GobackendScanLibraryFolderJSON(folderPath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "scanLibraryFolderIncremental":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let folderPath = args["folder_path"] as! String
|
||||
let existingFiles = args["existing_files"] as? String ?? "{}"
|
||||
let response = GobackendScanLibraryFolderIncrementalJSON(folderPath, existingFiles, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getLibraryScanProgress":
|
||||
let response = GobackendGetLibraryScanProgressJSON()
|
||||
return response
|
||||
|
||||
case "cancelLibraryScan":
|
||||
GobackendCancelLibraryScanJSON()
|
||||
return nil
|
||||
|
||||
case "readAudioMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
let response = GobackendReadAudioMetadataJSON(filePath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
default:
|
||||
throw NSError(
|
||||
domain: "SpotiFLAC",
|
||||
|
||||
+25
-1
@@ -67,7 +67,7 @@
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<!-- File Sharing - Allow access via Files app -->
|
||||
@@ -81,5 +81,29 @@
|
||||
<!-- Photo Library (for cover art if needed) -->
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>SpotiFLAC needs access to save album artwork</string>
|
||||
|
||||
<!-- URL Schemes for deep linking -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.zarz.spotiflac</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>spotiflac</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<!-- Associated Domains for Universal Links -->
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>spotify</string>
|
||||
<string>deezer</string>
|
||||
<string>tidal</string>
|
||||
<string>youtube-music</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+17
-1
@@ -4,15 +4,27 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:spotiflac_android/screens/main_shell.dart';
|
||||
import 'package:spotiflac_android/screens/setup_screen.dart';
|
||||
import 'package:spotiflac_android/screens/tutorial_screen.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
|
||||
final _routerProvider = Provider<GoRouter>((ref) {
|
||||
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||
final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial));
|
||||
|
||||
// Determine initial location based on app state
|
||||
String initialLocation;
|
||||
if (isFirstLaunch) {
|
||||
initialLocation = '/setup';
|
||||
} else if (!hasCompletedTutorial) {
|
||||
initialLocation = '/tutorial';
|
||||
} else {
|
||||
initialLocation = '/';
|
||||
}
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: isFirstLaunch ? '/setup' : '/',
|
||||
initialLocation: initialLocation,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
@@ -22,6 +34,10 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
||||
path: '/setup',
|
||||
builder: (context, state) => const SetupScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tutorial',
|
||||
builder: (context, state) => const TutorialScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.3.5';
|
||||
static const String buildNumber = '70';
|
||||
static const String version = '3.6.5';
|
||||
static const String buildNumber = '79';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
@@ -17,4 +17,5 @@ static const String version = '3.3.5';
|
||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||
|
||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||
}
|
||||
|
||||
+1181
-13
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Startseite';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'Verlauf';
|
||||
|
||||
@@ -349,6 +352,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Erweiterungen';
|
||||
|
||||
@@ -462,13 +469,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Spendiere mir einen Kaffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle =>
|
||||
'Unterstütze die Entwicklung auf Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -501,6 +501,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
|
||||
@@ -958,6 +965,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -1914,6 +1926,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1926,6 +1942,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2025,6 +2063,19 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2148,6 +2199,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
@@ -2247,4 +2304,649 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -340,6 +343,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Extensions';
|
||||
|
||||
@@ -450,12 +457,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -488,6 +489,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
@@ -943,6 +951,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -1899,6 +1912,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1911,6 +1928,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2010,6 +2049,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2133,6 +2185,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
@@ -2232,4 +2290,649 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -340,6 +343,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Extensions';
|
||||
|
||||
@@ -450,12 +457,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -488,6 +489,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
@@ -943,6 +951,11 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -1899,6 +1912,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1911,6 +1928,28 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2010,6 +2049,19 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2133,6 +2185,12 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
@@ -2232,6 +2290,651 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||
@@ -2670,12 +3373,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
@override
|
||||
String get aboutSupport => 'Soporte';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Invítame a un café';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Apoyar el desarrollo en Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'Aplicación';
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -340,6 +343,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Extensions';
|
||||
|
||||
@@ -450,12 +457,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -488,6 +489,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
@@ -943,6 +951,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -1899,6 +1912,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1911,6 +1928,28 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2010,6 +2049,19 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2133,6 +2185,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
@@ -2232,4 +2290,649 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'होम';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'इतिहास';
|
||||
|
||||
@@ -340,6 +343,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Extensions';
|
||||
|
||||
@@ -450,12 +457,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -488,6 +489,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
@@ -943,6 +951,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -1899,6 +1912,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1911,6 +1928,28 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2010,6 +2049,19 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2133,6 +2185,12 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
@@ -2232,4 +2290,649 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Beranda';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'Riwayat';
|
||||
|
||||
@@ -344,6 +347,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Ekstensi';
|
||||
|
||||
@@ -455,12 +462,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Dukungan';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Belikan saya kopi';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Dukung pengembangan di Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'Aplikasi';
|
||||
|
||||
@@ -493,6 +494,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
|
||||
@@ -949,6 +957,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return '\"$trackName\" sudah diunduh';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'Riwayat dihapus';
|
||||
|
||||
@@ -1911,6 +1924,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get qualityNote =>
|
||||
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||
|
||||
@@ -1923,6 +1940,29 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders =>
|
||||
'Gunakan Album Artist untuk folder';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Folder artis memakai Album Artist jika tersedia';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Folder artis hanya memakai Track Artist';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artist dihapus dari nama folder (misal Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Nama artis lengkap dipakai untuk folder';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Simpan Format';
|
||||
|
||||
@@ -2023,6 +2063,19 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'Tidak ada unduhan dalam antrian';
|
||||
|
||||
@@ -2146,6 +2199,12 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'Belum ada item terbaru';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Tampilkan Semua Download';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
@@ -2245,4 +2304,654 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Penyimpanan & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'Lihat ukuran dan bersihkan data cache';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Metadata, cover art, dan lirik otomatis tertanam';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Mencari Musik';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Tempel URL Spotify atau Deezer langsung di kotak pencarian';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Atau ketik nama lagu, artis, atau album untuk mencari';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Mendukung lagu, album, playlist, dan halaman artis';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Mengunduh Musik';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Mengunduh musik itu mudah dan cepat. Begini caranya.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Unduh seluruh album atau playlist dengan satu ketukan';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Perpustakaan Anda';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'Semua musik yang Anda unduh terorganisir di tab Perpustakaan.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'Lihat progres unduhan dan antrian di tab Perpustakaan';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Ketuk lagu mana pun untuk memutarnya dengan pemutar musik';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Ekstensi';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Jelajahi tab Toko untuk menemukan ekstensi berguna';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Tambahkan provider unduhan atau sumber pencarian baru';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Dapatkan lirik, metadata lebih baik, dan fitur lainnya';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Sesuaikan Pengalaman Anda';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Ubah lokasi unduhan dan organisasi folder';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Atur kualitas audio dan preferensi format default';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Sesuaikan tema dan tampilan aplikasi';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'Anda siap! Mulai unduh musik favorit Anda sekarang.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'CONTOH';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Pindai Ulang Penuh';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle =>
|
||||
'Pindai ulang semua file, abaikan cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Bersihkan Entri Unduhan Tidak Valid';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Hapus entri riwayat untuk file yang tidak ada lagi';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Menghapus $count entri unduhan tidak valid dari riwayat';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone =>
|
||||
'Tidak ada entri unduhan tidak valid';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Penyimpanan & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Ringkasan cache';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimasi penggunaan cache: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Data Cache';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Perawatan';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'Direktori cache aplikasi';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'Respons HTTP, data WebView, dan data sementara aplikasi.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Direktori sementara';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'File sementara dari proses download dan konversi audio.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cache gambar cover';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Cache cover library';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Cache feed Explore';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Cache pencocokan lagu';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Hapus entri riwayat download dan library yang filenya sudah tidak ada.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'Tidak ada data cache';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size dalam $count file';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entri';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Berhasil dibersihkan: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Bersihkan cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'Ini akan membersihkan data cache untuk $target. File musik yang sudah diunduh tidak akan dihapus.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Bersihkan semua cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Bersihkan semua cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Bersihkan data tidak terpakai';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Hapus riwayat unduhan yatim dan entri library yang file-nya hilang';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Pembersihan selesai: $downloadCount unduhan yatim, $libraryCount entri library hilang';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Segarkan statistik';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Simpan Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle =>
|
||||
'Simpan cover album sebagai file .jpg';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Simpan Lirik (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle =>
|
||||
'Ambil dan simpan lirik sebagai file .lrc';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Menyimpan lirik...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Perkaya Ulang Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Tanamkan ulang metadata tanpa mengunduh ulang';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Cari metadata dari internet dan tanamkan ke file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art disimpan ke $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'Tidak ada sumber cover art';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lirik disimpan ke $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Memperkaya ulang metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Mencari metadata dari internet...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata berhasil diperkaya ulang';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed =>
|
||||
'Gagal menanamkan metadata via FFmpeg';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Gagal: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Konversi Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Konversi ke MP3 atau Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Konversi Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Format Tujuan';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Konfirmasi Konversi';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Konversi dari $sourceFormat ke $targetFormat pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Mengkonversi audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Berhasil dikonversi ke $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Konversi gagal';
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'ホーム';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => '履歴';
|
||||
|
||||
@@ -337,6 +340,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify は独自の API 認証情報が必要です。developer.spotify.com から取得できます。';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => '拡張';
|
||||
|
||||
@@ -446,12 +453,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'サポート';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'コーヒーを買ってください';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'アプリ';
|
||||
|
||||
@@ -484,6 +485,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
|
||||
@@ -938,6 +946,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '「$trackName」は既にダウンロードされています';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => '履歴を消去しました';
|
||||
|
||||
@@ -1887,6 +1900,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
||||
|
||||
@@ -1899,6 +1916,28 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => '形式を保存';
|
||||
|
||||
@@ -1997,6 +2036,19 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'キューにダウンロードがありません';
|
||||
|
||||
@@ -2119,6 +2171,12 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'プレイリスト';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'プレイリスト: $name';
|
||||
@@ -2218,4 +2276,649 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -340,6 +343,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Extensions';
|
||||
|
||||
@@ -450,12 +457,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -488,6 +489,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
@@ -943,6 +951,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -1899,6 +1912,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1911,6 +1928,28 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2010,6 +2049,19 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2133,6 +2185,12 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
@@ -2232,4 +2290,649 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -340,6 +343,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Extensions';
|
||||
|
||||
@@ -450,12 +457,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -488,6 +489,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
@@ -943,6 +951,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -1899,6 +1912,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1911,6 +1928,28 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2010,6 +2049,19 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2133,6 +2185,12 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
@@ -2232,4 +2290,649 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -340,6 +343,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Extensions';
|
||||
|
||||
@@ -450,12 +457,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -488,6 +489,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
@@ -943,6 +951,11 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -1899,6 +1912,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1911,6 +1928,28 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2010,6 +2049,19 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2133,6 +2185,12 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
@@ -2232,6 +2290,651 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||
@@ -2669,12 +3372,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
@override
|
||||
String get aboutSupport => 'Apoiar';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Compre-me um café';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Apoie o desenvolvimento na Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'Aplicativo';
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Главная';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'История';
|
||||
|
||||
@@ -74,9 +77,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -87,9 +90,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count альбомов',
|
||||
one: '1 альбом',
|
||||
many: '$count альбомов',
|
||||
few: '$count альбома',
|
||||
one: '$count альбом',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -351,6 +354,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify требует ваши собственные учетные данные API. Получите их бесплатно на сайте developer.spotify.com';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Расширения';
|
||||
|
||||
@@ -463,12 +470,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Поддержка';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Купить мне кофе';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Поддержать разработку на Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'Приложение';
|
||||
|
||||
@@ -501,6 +502,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'Лучший API для стриминга Qobuz. Без него загрузка файлов в высоком разрешении была бы невозможна!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.';
|
||||
@@ -514,9 +522,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -548,9 +556,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count релизов',
|
||||
one: '1 релиз',
|
||||
many: '$count релизов',
|
||||
few: '$count релиза',
|
||||
one: '$count релиз',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -930,9 +938,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.';
|
||||
}
|
||||
@@ -965,6 +973,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return '\"$trackName\" уже скачан';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'История очищена';
|
||||
|
||||
@@ -980,9 +993,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалено $count $_temp0';
|
||||
}
|
||||
@@ -1129,9 +1142,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0';
|
||||
}
|
||||
@@ -1573,9 +1586,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -1937,6 +1950,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get qualityNote =>
|
||||
'Фактическое качество зависит от доступности треков в сервисе';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
||||
|
||||
@@ -1949,6 +1966,28 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Структура папок альбома';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Формат сохранения';
|
||||
|
||||
@@ -2049,6 +2088,19 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'Нет загрузок в очереди';
|
||||
|
||||
@@ -2116,9 +2168,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.';
|
||||
}
|
||||
@@ -2148,9 +2200,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0';
|
||||
}
|
||||
@@ -2178,6 +2230,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'Плейлист';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Плейлист: $name';
|
||||
@@ -2278,4 +2336,649 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Ara';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'Geçmiş';
|
||||
|
||||
@@ -345,6 +348,10 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify\'ın senin API kimlik bilgilerine ihtiyacı var. Onları developer.spotify.com\'dan alabilirsin';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Eklentiler';
|
||||
|
||||
@@ -457,12 +464,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Destek';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Bana bir kahve ısmarla';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi üzerinden uygulamayı destekle';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'Uygulama';
|
||||
|
||||
@@ -495,6 +496,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'En iyi Qobuz streaming API\'ı. Yüksek kalite indirmeler bunun sayesinde!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.';
|
||||
@@ -950,6 +958,11 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
return '\"$trackName\" zaten indirilmiş';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'Geçmiş temizlendi';
|
||||
|
||||
@@ -1914,6 +1927,10 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1926,6 +1943,28 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2025,6 +2064,19 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2148,6 +2200,12 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
@@ -2247,4 +2305,649 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -340,6 +343,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Extensions';
|
||||
|
||||
@@ -450,12 +457,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -488,6 +489,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get aboutDabMusicDesc =>
|
||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaver => 'SpotiSaver';
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
@@ -943,6 +951,11 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -1899,6 +1912,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1911,6 +1928,28 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Save Format';
|
||||
|
||||
@@ -2010,6 +2049,19 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2133,6 +2185,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
@@ -2232,6 +2290,651 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheSizeOnly(String size) {
|
||||
return '$size';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||
@@ -2680,12 +3383,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
@@ -4826,12 +5523,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
@override
|
||||
String get aboutSupport => 'Support';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
||||
|
||||
@override
|
||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutApp => 'App';
|
||||
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Spendiere mir einen Kaffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Unterstütze die Entwicklung auf Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
+550
-6
@@ -9,8 +9,10 @@
|
||||
|
||||
"navHome": "Home",
|
||||
"@navHome": {"description": "Bottom navigation - Home tab"},
|
||||
"navLibrary": "Library",
|
||||
"@navLibrary": {"description": "Bottom navigation - Library tab"},
|
||||
"navHistory": "History",
|
||||
"@navHistory": {"description": "Bottom navigation - History tab"},
|
||||
"@navHistory": {"description": "Bottom navigation - History tab (legacy)"},
|
||||
"navSettings": "Settings",
|
||||
"@navSettings": {"description": "Bottom navigation - Settings tab"},
|
||||
"navStore": "Store",
|
||||
@@ -239,6 +241,8 @@
|
||||
"@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"},
|
||||
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
|
||||
"@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"},
|
||||
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
|
||||
"@optionsSpotifyDeprecationWarning": {"description": "Warning about Spotify API deprecation"},
|
||||
|
||||
"extensionsTitle": "Extensions",
|
||||
"@extensionsTitle": {"description": "Extensions page title"},
|
||||
@@ -322,10 +326,6 @@
|
||||
"@aboutSocial": {"description": "Section for social links"},
|
||||
"aboutSupport": "Support",
|
||||
"@aboutSupport": {"description": "Section for support/donation links"},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {"description": "Donation link"},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {"description": "Subtitle for donation"},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {"description": "Section for app info"},
|
||||
"aboutVersion": "Version",
|
||||
@@ -344,6 +344,10 @@
|
||||
"@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"},
|
||||
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
|
||||
"@aboutDabMusicDesc": {"description": "Credit for DAB Music API"},
|
||||
"aboutSpotiSaver": "SpotiSaver",
|
||||
"@aboutSpotiSaver": {"description": "Name of SpotiSaver API service - DO NOT TRANSLATE"},
|
||||
"aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!",
|
||||
"@aboutSpotiSaverDesc": {"description": "Credit for SpotiSaver API"},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||
"@aboutAppDescription": {"description": "App description in header card"},
|
||||
|
||||
@@ -670,6 +674,13 @@
|
||||
"trackName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library",
|
||||
"@snackbarAlreadyInLibrary": {
|
||||
"description": "Snackbar - track already exists in local library",
|
||||
"placeholders": {
|
||||
"trackName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"snackbarHistoryCleared": "History cleared",
|
||||
"@snackbarHistoryCleared": {"description": "Snackbar - history deleted"},
|
||||
"snackbarCredentialsSaved": "Credentials saved",
|
||||
@@ -1399,6 +1410,8 @@
|
||||
"@lossyFormatOpusSubtitle": {"description": "Opus format description"},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {"description": "Note about quality availability"},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"},
|
||||
|
||||
"downloadAskBeforeDownload": "Ask Before Download",
|
||||
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
|
||||
@@ -1408,6 +1421,18 @@
|
||||
"@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"},
|
||||
"downloadAlbumFolderStructure": "Album Folder Structure",
|
||||
"@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"},
|
||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||
"@downloadUseAlbumArtistForFolders": {"description": "Setting - choose whether artist folders use Album Artist or Track Artist"},
|
||||
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available",
|
||||
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
|
||||
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
|
||||
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
||||
"@downloadUsePrimaryArtistOnly": {"description": "Setting - strip featured artists from folder name"},
|
||||
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
|
||||
"@downloadUsePrimaryArtistOnlyEnabled": {"description": "Subtitle when primary artist only is enabled"},
|
||||
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
||||
"@downloadUsePrimaryArtistOnlyDisabled": {"description": "Subtitle when primary artist only is disabled"},
|
||||
"downloadSaveFormat": "Save Format",
|
||||
"@downloadSaveFormat": {"description": "Setting - output file format"},
|
||||
"downloadSelectService": "Select Service",
|
||||
@@ -1476,6 +1501,16 @@
|
||||
"@settingsAutoExportFailed": {"description": "Setting toggle for auto-export"},
|
||||
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
|
||||
"@settingsAutoExportFailedSubtitle": {"description": "Subtitle for auto-export setting"},
|
||||
|
||||
"settingsDownloadNetwork": "Download Network",
|
||||
"@settingsDownloadNetwork": {"description": "Setting for network type preference"},
|
||||
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
|
||||
"@settingsDownloadNetworkAny": {"description": "Network option - use any connection"},
|
||||
"settingsDownloadNetworkWifiOnly": "WiFi Only",
|
||||
"@settingsDownloadNetworkWifiOnly": {"description": "Network option - only use WiFi"},
|
||||
"settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.",
|
||||
"@settingsDownloadNetworkSubtitle": {"description": "Subtitle explaining network preference"},
|
||||
|
||||
"queueEmpty": "No downloads in queue",
|
||||
"@queueEmpty": {"description": "Empty queue state title"},
|
||||
"queueEmptySubtitle": "Add tracks from the home screen",
|
||||
@@ -1571,6 +1606,12 @@
|
||||
"@recentTypeSong": {"description": "Recent access item type - song/track"},
|
||||
"recentTypePlaylist": "Playlist",
|
||||
"@recentTypePlaylist": {"description": "Recent access item type - playlist"},
|
||||
"recentEmpty": "No recent items yet",
|
||||
"@recentEmpty": {"description": "Empty state text for recent access list"},
|
||||
"recentShowAllDownloads": "Show All Downloads",
|
||||
"@recentShowAllDownloads": {
|
||||
"description": "Button label to unhide hidden downloads in recent access"
|
||||
},
|
||||
|
||||
"recentPlaylistInfo": "Playlist: {name}",
|
||||
"@recentPlaylistInfo": {
|
||||
@@ -1675,5 +1716,508 @@
|
||||
"allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.",
|
||||
"@allFilesAccessDeniedMessage": {"description": "Message when permission is permanently denied"},
|
||||
"allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.",
|
||||
"@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"}
|
||||
"@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"},
|
||||
|
||||
"settingsLocalLibrary": "Local Library",
|
||||
"@settingsLocalLibrary": {"description": "Settings menu item - local library"},
|
||||
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
|
||||
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"},
|
||||
"settingsCache": "Storage & Cache",
|
||||
"@settingsCache": {"description": "Settings menu item - cache management"},
|
||||
"settingsCacheSubtitle": "View size and clear cached data",
|
||||
"@settingsCacheSubtitle": {"description": "Subtitle for cache management menu"},
|
||||
"libraryTitle": "Local Library",
|
||||
"@libraryTitle": {"description": "Library settings page title"},
|
||||
"libraryStatus": "Library Status",
|
||||
"@libraryStatus": {"description": "Section header for library status"},
|
||||
"libraryScanSettings": "Scan Settings",
|
||||
"@libraryScanSettings": {"description": "Section header for scan settings"},
|
||||
"libraryEnableLocalLibrary": "Enable Local Library",
|
||||
"@libraryEnableLocalLibrary": {"description": "Toggle to enable library scanning"},
|
||||
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
|
||||
"@libraryEnableLocalLibrarySubtitle": {"description": "Subtitle for enable toggle"},
|
||||
"libraryFolder": "Library Folder",
|
||||
"@libraryFolder": {"description": "Folder selection setting"},
|
||||
"libraryFolderHint": "Tap to select folder",
|
||||
"@libraryFolderHint": {"description": "Placeholder when no folder selected"},
|
||||
"libraryShowDuplicateIndicator": "Show Duplicate Indicator",
|
||||
"@libraryShowDuplicateIndicator": {"description": "Toggle for duplicate indicator in search"},
|
||||
"libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks",
|
||||
"@libraryShowDuplicateIndicatorSubtitle": {"description": "Subtitle for duplicate indicator toggle"},
|
||||
"libraryActions": "Actions",
|
||||
"@libraryActions": {"description": "Section header for library actions"},
|
||||
"libraryScan": "Scan Library",
|
||||
"@libraryScan": {"description": "Button to start library scan"},
|
||||
"libraryScanSubtitle": "Scan for audio files",
|
||||
"@libraryScanSubtitle": {"description": "Subtitle for scan button"},
|
||||
"libraryScanSelectFolderFirst": "Select a folder first",
|
||||
"@libraryScanSelectFolderFirst": {"description": "Message when trying to scan without folder"},
|
||||
"libraryCleanupMissingFiles": "Cleanup Missing Files",
|
||||
"@libraryCleanupMissingFiles": {"description": "Button to remove entries for missing files"},
|
||||
"libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist",
|
||||
"@libraryCleanupMissingFilesSubtitle": {"description": "Subtitle for cleanup button"},
|
||||
"libraryClear": "Clear Library",
|
||||
"@libraryClear": {"description": "Button to clear all library entries"},
|
||||
"libraryClearSubtitle": "Remove all scanned tracks",
|
||||
"@libraryClearSubtitle": {"description": "Subtitle for clear button"},
|
||||
"libraryClearConfirmTitle": "Clear Library",
|
||||
"@libraryClearConfirmTitle": {"description": "Dialog title for clear confirmation"},
|
||||
"libraryClearConfirmMessage": "This will remove all scanned tracks from your library. Your actual music files will not be deleted.",
|
||||
"@libraryClearConfirmMessage": {"description": "Dialog message for clear confirmation"},
|
||||
"libraryAbout": "About Local Library",
|
||||
"@libraryAbout": {"description": "Section header for about info"},
|
||||
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.",
|
||||
"@libraryAboutDescription": {"description": "Description of local library feature"},
|
||||
"libraryTracksCount": "{count} tracks",
|
||||
"@libraryTracksCount": {
|
||||
"description": "Track count in library",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryLastScanned": "Last scanned: {time}",
|
||||
"@libraryLastScanned": {
|
||||
"description": "Last scan time display",
|
||||
"placeholders": {
|
||||
"time": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"libraryLastScannedNever": "Never",
|
||||
"@libraryLastScannedNever": {"description": "Shown when library has never been scanned"},
|
||||
"libraryScanning": "Scanning...",
|
||||
"@libraryScanning": {"description": "Status during scan"},
|
||||
"libraryScanProgress": "{progress}% of {total} files",
|
||||
"@libraryScanProgress": {
|
||||
"description": "Scan progress display",
|
||||
"placeholders": {
|
||||
"progress": {"type": "String"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryInLibrary": "In Library",
|
||||
"@libraryInLibrary": {"description": "Badge shown on tracks that exist in local library"},
|
||||
"libraryRemovedMissingFiles": "Removed {count} missing files from library",
|
||||
"@libraryRemovedMissingFiles": {
|
||||
"description": "Snackbar after cleanup",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryCleared": "Library cleared",
|
||||
"@libraryCleared": {"description": "Snackbar after clearing library"},
|
||||
"libraryStorageAccessRequired": "Storage Access Required",
|
||||
"@libraryStorageAccessRequired": {"description": "Dialog title for storage permission"},
|
||||
"libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.",
|
||||
"@libraryStorageAccessMessage": {"description": "Dialog message for storage permission"},
|
||||
"libraryFolderNotExist": "Selected folder does not exist",
|
||||
"@libraryFolderNotExist": {"description": "Error when folder doesn't exist"},
|
||||
"librarySourceDownloaded": "Downloaded",
|
||||
"@librarySourceDownloaded": {"description": "Badge for tracks downloaded via SpotiFLAC"},
|
||||
"librarySourceLocal": "Local",
|
||||
"@librarySourceLocal": {"description": "Badge for tracks from local library scan"},
|
||||
"libraryFilterAll": "All",
|
||||
"@libraryFilterAll": {"description": "Filter chip - show all library items"},
|
||||
"libraryFilterDownloaded": "Downloaded",
|
||||
"@libraryFilterDownloaded": {"description": "Filter chip - show only downloaded items"},
|
||||
"libraryFilterLocal": "Local",
|
||||
"@libraryFilterLocal": {"description": "Filter chip - show only local library items"},
|
||||
|
||||
"libraryFilterTitle": "Filters",
|
||||
"@libraryFilterTitle": {"description": "Filter bottom sheet title"},
|
||||
"libraryFilterReset": "Reset",
|
||||
"@libraryFilterReset": {"description": "Reset all filters button"},
|
||||
"libraryFilterApply": "Apply",
|
||||
"@libraryFilterApply": {"description": "Apply filters button"},
|
||||
"libraryFilterSource": "Source",
|
||||
"@libraryFilterSource": {"description": "Filter section - source type"},
|
||||
"libraryFilterQuality": "Quality",
|
||||
"@libraryFilterQuality": {"description": "Filter section - audio quality"},
|
||||
"libraryFilterQualityHiRes": "Hi-Res (24bit)",
|
||||
"@libraryFilterQualityHiRes": {"description": "Filter option - high resolution audio"},
|
||||
"libraryFilterQualityCD": "CD (16bit)",
|
||||
"@libraryFilterQualityCD": {"description": "Filter option - CD quality audio"},
|
||||
"libraryFilterQualityLossy": "Lossy",
|
||||
"@libraryFilterQualityLossy": {"description": "Filter option - lossy compressed audio"},
|
||||
"libraryFilterFormat": "Format",
|
||||
"@libraryFilterFormat": {"description": "Filter section - file format"},
|
||||
"libraryFilterDate": "Date Added",
|
||||
"@libraryFilterDate": {"description": "Filter section - date range"},
|
||||
"libraryFilterDateToday": "Today",
|
||||
"@libraryFilterDateToday": {"description": "Filter option - today only"},
|
||||
"libraryFilterDateWeek": "This Week",
|
||||
"@libraryFilterDateWeek": {"description": "Filter option - this week"},
|
||||
"libraryFilterDateMonth": "This Month",
|
||||
"@libraryFilterDateMonth": {"description": "Filter option - this month"},
|
||||
"libraryFilterDateYear": "This Year",
|
||||
"@libraryFilterDateYear": {"description": "Filter option - this year"},
|
||||
"libraryFilterSort": "Sort",
|
||||
"@libraryFilterSort": {"description": "Filter section - sort order"},
|
||||
"libraryFilterSortLatest": "Latest",
|
||||
"@libraryFilterSortLatest": {"description": "Sort option - newest first"},
|
||||
"libraryFilterSortOldest": "Oldest",
|
||||
"@libraryFilterSortOldest": {"description": "Sort option - oldest first"},
|
||||
"libraryFilterActive": "{count} filter(s) active",
|
||||
"@libraryFilterActive": {
|
||||
"description": "Badge showing number of active filters",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
|
||||
"timeJustNow": "Just now",
|
||||
"@timeJustNow": {"description": "Relative time - less than a minute ago"},
|
||||
"timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
|
||||
"@timeMinutesAgo": {
|
||||
"description": "Relative time - minutes ago",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
|
||||
"@timeHoursAgo": {
|
||||
"description": "Relative time - hours ago",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
|
||||
"storageSwitchTitle": "Switch Storage Mode",
|
||||
"@storageSwitchTitle": {"description": "Dialog title when switching storage mode"},
|
||||
"storageSwitchToSafTitle": "Switch to SAF Storage?",
|
||||
"@storageSwitchToSafTitle": {"description": "Dialog title when switching to SAF"},
|
||||
"storageSwitchToAppTitle": "Switch to App Storage?",
|
||||
"@storageSwitchToAppTitle": {"description": "Dialog title when switching to app storage"},
|
||||
"storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.",
|
||||
"@storageSwitchToSafMessage": {"description": "Explanation when switching to SAF"},
|
||||
"storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.",
|
||||
"@storageSwitchToAppMessage": {"description": "Explanation when switching to app storage"},
|
||||
"storageSwitchExistingDownloads": "Existing Downloads",
|
||||
"@storageSwitchExistingDownloads": {"description": "Section header for existing downloads info"},
|
||||
"storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage",
|
||||
"@storageSwitchExistingDownloadsInfo": {
|
||||
"description": "Info about existing downloads count",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"mode": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"storageSwitchNewDownloads": "New Downloads",
|
||||
"@storageSwitchNewDownloads": {"description": "Section header for new downloads info"},
|
||||
"storageSwitchNewDownloadsLocation": "Will be saved to: {location}",
|
||||
"@storageSwitchNewDownloadsLocation": {
|
||||
"description": "Shows where new downloads will go",
|
||||
"placeholders": {
|
||||
"location": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"storageSwitchContinue": "Continue",
|
||||
"@storageSwitchContinue": {"description": "Button to proceed with storage switch"},
|
||||
"storageSwitchSelectFolder": "Select SAF Folder",
|
||||
"@storageSwitchSelectFolder": {"description": "Button to select SAF folder"},
|
||||
"storageAppStorage": "App Storage",
|
||||
"@storageAppStorage": {"description": "Label for app storage mode"},
|
||||
"storageSafStorage": "SAF Storage",
|
||||
"@storageSafStorage": {"description": "Label for SAF storage mode"},
|
||||
"storageModeBadge": "Storage: {mode}",
|
||||
"@storageModeBadge": {
|
||||
"description": "Badge showing storage mode for a track",
|
||||
"placeholders": {
|
||||
"mode": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"storageStatsTitle": "Storage Statistics",
|
||||
"@storageStatsTitle": {"description": "Section title for storage stats"},
|
||||
"storageStatsAppCount": "{count} tracks in App Storage",
|
||||
"@storageStatsAppCount": {
|
||||
"description": "Count of tracks in app storage",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"storageStatsSafCount": "{count} tracks in SAF Storage",
|
||||
"@storageStatsSafCount": {
|
||||
"description": "Count of tracks in SAF storage",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"storageModeInfo": "Your files are stored in multiple locations",
|
||||
"@storageModeInfo": {"description": "Info when user has files in both storage modes"},
|
||||
|
||||
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
|
||||
"@tutorialWelcomeTitle": {"description": "Tutorial welcome page title"},
|
||||
"tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.",
|
||||
"@tutorialWelcomeDesc": {"description": "Tutorial welcome page description"},
|
||||
"tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL",
|
||||
"@tutorialWelcomeTip1": {"description": "Tutorial welcome tip 1"},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||
"@tutorialWelcomeTip2": {"description": "Tutorial welcome tip 2"},
|
||||
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding",
|
||||
"@tutorialWelcomeTip3": {"description": "Tutorial welcome tip 3"},
|
||||
|
||||
"tutorialSearchTitle": "Finding Music",
|
||||
"@tutorialSearchTitle": {"description": "Tutorial search page title"},
|
||||
"tutorialSearchDesc": "There are two easy ways to find music you want to download.",
|
||||
"@tutorialSearchDesc": {"description": "Tutorial search page description"},
|
||||
"tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box",
|
||||
"@tutorialSearchTip1": {"description": "Tutorial search tip 1"},
|
||||
"tutorialSearchTip2": "Or type the song name, artist, or album to search",
|
||||
"@tutorialSearchTip2": {"description": "Tutorial search tip 2"},
|
||||
"tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages",
|
||||
"@tutorialSearchTip3": {"description": "Tutorial search tip 3"},
|
||||
|
||||
"tutorialDownloadTitle": "Downloading Music",
|
||||
"@tutorialDownloadTitle": {"description": "Tutorial download page title"},
|
||||
"tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.",
|
||||
"@tutorialDownloadDesc": {"description": "Tutorial download page description"},
|
||||
"tutorialDownloadTip1": "Tap the download button next to any track to start downloading",
|
||||
"@tutorialDownloadTip1": {"description": "Tutorial download tip 1"},
|
||||
"tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)",
|
||||
"@tutorialDownloadTip2": {"description": "Tutorial download tip 2"},
|
||||
"tutorialDownloadTip3": "Download entire albums or playlists with one tap",
|
||||
"@tutorialDownloadTip3": {"description": "Tutorial download tip 3"},
|
||||
|
||||
"tutorialLibraryTitle": "Your Library",
|
||||
"@tutorialLibraryTitle": {"description": "Tutorial library page title"},
|
||||
"tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.",
|
||||
"@tutorialLibraryDesc": {"description": "Tutorial library page description"},
|
||||
"tutorialLibraryTip1": "View download progress and queue in the Library tab",
|
||||
"@tutorialLibraryTip1": {"description": "Tutorial library tip 1"},
|
||||
"tutorialLibraryTip2": "Tap any track to play it with your music player",
|
||||
"@tutorialLibraryTip2": {"description": "Tutorial library tip 2"},
|
||||
"tutorialLibraryTip3": "Switch between list and grid view for better browsing",
|
||||
"@tutorialLibraryTip3": {"description": "Tutorial library tip 3"},
|
||||
|
||||
"tutorialExtensionsTitle": "Extensions",
|
||||
"@tutorialExtensionsTitle": {"description": "Tutorial extensions page title"},
|
||||
"tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.",
|
||||
"@tutorialExtensionsDesc": {"description": "Tutorial extensions page description"},
|
||||
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
||||
"@tutorialExtensionsTip1": {"description": "Tutorial extensions tip 1"},
|
||||
"tutorialExtensionsTip2": "Add new download providers or search sources",
|
||||
"@tutorialExtensionsTip2": {"description": "Tutorial extensions tip 2"},
|
||||
"tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features",
|
||||
"@tutorialExtensionsTip3": {"description": "Tutorial extensions tip 3"},
|
||||
|
||||
"tutorialSettingsTitle": "Customize Your Experience",
|
||||
"@tutorialSettingsTitle": {"description": "Tutorial settings page title"},
|
||||
"tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.",
|
||||
"@tutorialSettingsDesc": {"description": "Tutorial settings page description"},
|
||||
"tutorialSettingsTip1": "Change download location and folder organization",
|
||||
"@tutorialSettingsTip1": {"description": "Tutorial settings tip 1"},
|
||||
"tutorialSettingsTip2": "Set default audio quality and format preferences",
|
||||
"@tutorialSettingsTip2": {"description": "Tutorial settings tip 2"},
|
||||
"tutorialSettingsTip3": "Customize app theme and appearance",
|
||||
"@tutorialSettingsTip3": {"description": "Tutorial settings tip 3"},
|
||||
|
||||
"tutorialReadyMessage": "You're all set! Start downloading your favorite music now.",
|
||||
"@tutorialReadyMessage": {"description": "Tutorial completion message"},
|
||||
"tutorialExample": "EXAMPLE",
|
||||
"@tutorialExample": {"description": "Example label in tutorial"},
|
||||
|
||||
"libraryForceFullScan": "Force Full Scan",
|
||||
"@libraryForceFullScan": {"description": "Button to force a complete rescan of library"},
|
||||
"libraryForceFullScanSubtitle": "Rescan all files, ignoring cache",
|
||||
"@libraryForceFullScanSubtitle": {"description": "Subtitle for force full scan button"},
|
||||
|
||||
"cleanupOrphanedDownloads": "Cleanup Orphaned Downloads",
|
||||
"@cleanupOrphanedDownloads": {"description": "Button to remove history entries for deleted files"},
|
||||
"cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist",
|
||||
"@cleanupOrphanedDownloadsSubtitle": {"description": "Subtitle for orphaned cleanup button"},
|
||||
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
|
||||
"@cleanupOrphanedDownloadsResult": {
|
||||
"description": "Snackbar after orphan cleanup",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"cleanupOrphanedDownloadsNone": "No orphaned entries found",
|
||||
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"},
|
||||
|
||||
"cacheTitle": "Storage & Cache",
|
||||
"@cacheTitle": {"description": "Cache management page title"},
|
||||
"cacheSummaryTitle": "Cache overview",
|
||||
"@cacheSummaryTitle": {"description": "Heading for cache summary card"},
|
||||
"cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.",
|
||||
"@cacheSummarySubtitle": {"description": "Helper text for cache summary card"},
|
||||
"cacheEstimatedTotal": "Estimated cache usage: {size}",
|
||||
"@cacheEstimatedTotal": {
|
||||
"description": "Total cache size shown in summary",
|
||||
"placeholders": {
|
||||
"size": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"cacheSectionStorage": "Cached Data",
|
||||
"@cacheSectionStorage": {"description": "Section header for cache entries"},
|
||||
"cacheSectionMaintenance": "Maintenance",
|
||||
"@cacheSectionMaintenance": {"description": "Section header for cleanup actions"},
|
||||
"cacheAppDirectory": "App cache directory",
|
||||
"@cacheAppDirectory": {"description": "Cache item title for app cache directory"},
|
||||
"cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.",
|
||||
"@cacheAppDirectoryDesc": {"description": "Description of what app cache directory contains"},
|
||||
"cacheTempDirectory": "Temporary directory",
|
||||
"@cacheTempDirectory": {"description": "Cache item title for temporary files directory"},
|
||||
"cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.",
|
||||
"@cacheTempDirectoryDesc": {"description": "Description of what temporary directory contains"},
|
||||
"cacheCoverImage": "Cover image cache",
|
||||
"@cacheCoverImage": {"description": "Cache item title for persistent cover images"},
|
||||
"cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.",
|
||||
"@cacheCoverImageDesc": {"description": "Description of what cover image cache contains"},
|
||||
"cacheLibraryCover": "Library cover cache",
|
||||
"@cacheLibraryCover": {"description": "Cache item title for local library cover art images"},
|
||||
"cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.",
|
||||
"@cacheLibraryCoverDesc": {"description": "Description of what library cover cache contains"},
|
||||
"cacheExploreFeed": "Explore feed cache",
|
||||
"@cacheExploreFeed": {"description": "Cache item title for explore home feed cache"},
|
||||
"cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.",
|
||||
"@cacheExploreFeedDesc": {"description": "Description of what explore feed cache contains"},
|
||||
"cacheTrackLookup": "Track lookup cache",
|
||||
"@cacheTrackLookup": {"description": "Cache item title for track ID lookup cache"},
|
||||
"cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.",
|
||||
"@cacheTrackLookupDesc": {"description": "Description of what track lookup cache contains"},
|
||||
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.",
|
||||
"@cacheCleanupUnusedDesc": {"description": "Description of what cleanup unused data does"},
|
||||
"cacheNoData": "No cached data",
|
||||
"@cacheNoData": {"description": "Label when cache category has no data"},
|
||||
"cacheSizeWithFiles": "{size} in {count} files",
|
||||
"@cacheSizeWithFiles": {
|
||||
"description": "Cache size and file count",
|
||||
"placeholders": {
|
||||
"size": {"type": "String"},
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"cacheSizeOnly": "{size}",
|
||||
"@cacheSizeOnly": {
|
||||
"description": "Cache size only",
|
||||
"placeholders": {
|
||||
"size": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"cacheEntries": "{count} entries",
|
||||
"@cacheEntries": {
|
||||
"description": "Track cache entry count",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"cacheClearSuccess": "Cleared: {target}",
|
||||
"@cacheClearSuccess": {
|
||||
"description": "Snackbar after clearing selected cache",
|
||||
"placeholders": {
|
||||
"target": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"cacheClearConfirmTitle": "Clear cache?",
|
||||
"@cacheClearConfirmTitle": {"description": "Dialog title before clearing one cache category"},
|
||||
"cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.",
|
||||
"@cacheClearConfirmMessage": {
|
||||
"description": "Dialog message before clearing selected cache",
|
||||
"placeholders": {
|
||||
"target": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"cacheClearAllConfirmTitle": "Clear all cache?",
|
||||
"@cacheClearAllConfirmTitle": {"description": "Dialog title before clearing all caches"},
|
||||
"cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.",
|
||||
"@cacheClearAllConfirmMessage": {"description": "Dialog message before clearing all caches"},
|
||||
"cacheClearAll": "Clear all cache",
|
||||
"@cacheClearAll": {"description": "Button label to clear all caches"},
|
||||
"cacheCleanupUnused": "Cleanup unused data",
|
||||
"@cacheCleanupUnused": {"description": "Action title for cleaning unused entries"},
|
||||
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries",
|
||||
"@cacheCleanupUnusedSubtitle": {"description": "Subtitle for cleanup unused data action"},
|
||||
"cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries",
|
||||
"@cacheCleanupResult": {
|
||||
"description": "Snackbar after unused data cleanup",
|
||||
"placeholders": {
|
||||
"downloadCount": {"type": "int"},
|
||||
"libraryCount": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"cacheRefreshStats": "Refresh stats",
|
||||
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"},
|
||||
|
||||
"trackSaveCoverArt": "Save Cover Art",
|
||||
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
|
||||
"trackSaveCoverArtSubtitle": "Save album art as .jpg file",
|
||||
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
|
||||
"trackSaveLyrics": "Save Lyrics (.lrc)",
|
||||
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
||||
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
|
||||
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
||||
"trackSaveLyricsProgress": "Saving lyrics...",
|
||||
"@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"},
|
||||
"trackReEnrich": "Re-enrich Metadata",
|
||||
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
|
||||
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
|
||||
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
|
||||
"trackReEnrichOnlineSubtitle": "Search metadata online and embed into file",
|
||||
"@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"},
|
||||
"trackEditMetadata": "Edit Metadata",
|
||||
"@trackEditMetadata": {"description": "Menu action - edit embedded metadata"},
|
||||
"trackCoverSaved": "Cover art saved to {fileName}",
|
||||
"@trackCoverSaved": {
|
||||
"description": "Snackbar after cover art saved",
|
||||
"placeholders": {
|
||||
"fileName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"trackCoverNoSource": "No cover art source available",
|
||||
"@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"},
|
||||
"trackLyricsSaved": "Lyrics saved to {fileName}",
|
||||
"@trackLyricsSaved": {
|
||||
"description": "Snackbar after lyrics saved",
|
||||
"placeholders": {
|
||||
"fileName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"trackReEnrichProgress": "Re-enriching metadata...",
|
||||
"@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"},
|
||||
"trackReEnrichSearching": "Searching metadata online...",
|
||||
"@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"},
|
||||
"trackReEnrichSuccess": "Metadata re-enriched successfully",
|
||||
"@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"},
|
||||
"trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed",
|
||||
"@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"},
|
||||
"trackSaveFailed": "Failed: {error}",
|
||||
"@trackSaveFailed": {
|
||||
"description": "Snackbar when save operation fails",
|
||||
"placeholders": {
|
||||
"error": {"type": "String"}
|
||||
}
|
||||
},
|
||||
|
||||
"trackConvertFormat": "Convert Format",
|
||||
"@trackConvertFormat": {"description": "Menu item - convert audio format"},
|
||||
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||
"@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"},
|
||||
"trackConvertTitle": "Convert Audio",
|
||||
"@trackConvertTitle": {"description": "Title of convert bottom sheet"},
|
||||
"trackConvertTargetFormat": "Target Format",
|
||||
"@trackConvertTargetFormat": {"description": "Label for format selection"},
|
||||
"trackConvertBitrate": "Bitrate",
|
||||
"@trackConvertBitrate": {"description": "Label for bitrate selection"},
|
||||
"trackConvertConfirmTitle": "Confirm Conversion",
|
||||
"@trackConvertConfirmTitle": {"description": "Confirmation dialog title"},
|
||||
"trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.",
|
||||
"@trackConvertConfirmMessage": {
|
||||
"description": "Confirmation dialog message",
|
||||
"placeholders": {
|
||||
"sourceFormat": {"type": "String"},
|
||||
"targetFormat": {"type": "String"},
|
||||
"bitrate": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"trackConvertConverting": "Converting audio...",
|
||||
"@trackConvertConverting": {"description": "Snackbar while converting"},
|
||||
"trackConvertSuccess": "Converted to {format} successfully",
|
||||
"@trackConvertSuccess": {
|
||||
"description": "Snackbar after successful conversion",
|
||||
"placeholders": {
|
||||
"format": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"trackConvertFailed": "Conversion failed",
|
||||
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
|
||||
}
|
||||
|
||||
@@ -548,14 +548,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -548,14 +548,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Invítame a un café",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Apoyar el desarrollo en Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "Aplicación",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
+3245
-2868
File diff suppressed because it is too large
Load Diff
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "コーヒーを買ってください",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Ko-fi で開発をサポートします",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "アプリ",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -548,14 +548,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -548,14 +548,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Compre-me um café",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Apoie o desenvolvimento na Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "Aplicativo",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
+10
-18
@@ -85,7 +85,7 @@
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
|
||||
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||
"@historyTracksCount": {
|
||||
"description": "Track count with plural form",
|
||||
"placeholders": {
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} =1 {1 альбом} other {{count} альбомов}}",
|
||||
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} other {{count} альбомов}}",
|
||||
"@historyAlbumsCount": {
|
||||
"description": "Album count with plural form",
|
||||
"placeholders": {
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Купить мне кофе",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Поддержать разработку на Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "Приложение",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
@@ -624,7 +616,7 @@
|
||||
"@albumTitle": {
|
||||
"description": "Album screen title"
|
||||
},
|
||||
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
|
||||
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||
"@albumTracks": {
|
||||
"description": "Album track count",
|
||||
"placeholders": {
|
||||
@@ -661,7 +653,7 @@
|
||||
"@artistCompilations": {
|
||||
"description": "Section header for compilations"
|
||||
},
|
||||
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} =1 {1 релиз} other {{count} релизов}}",
|
||||
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} other {{count} релизов}}",
|
||||
"@artistReleases": {
|
||||
"description": "Artist release count",
|
||||
"placeholders": {
|
||||
@@ -1136,7 +1128,7 @@
|
||||
"@dialogDeleteSelectedTitle": {
|
||||
"description": "Dialog title - delete selected items"
|
||||
},
|
||||
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
|
||||
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
|
||||
"@dialogDeleteSelectedMessage": {
|
||||
"description": "Dialog message - delete selected tracks",
|
||||
"placeholders": {
|
||||
@@ -1206,7 +1198,7 @@
|
||||
"@snackbarCredentialsCleared": {
|
||||
"description": "Snackbar - Spotify credentials removed"
|
||||
},
|
||||
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
|
||||
"@snackbarDeletedTracks": {
|
||||
"description": "Snackbar - tracks deleted",
|
||||
"placeholders": {
|
||||
@@ -1413,7 +1405,7 @@
|
||||
"@selectionTapToSelect": {
|
||||
"description": "Hint - how to select items"
|
||||
},
|
||||
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
|
||||
"@selectionDeleteTracks": {
|
||||
"description": "Delete button with count",
|
||||
"placeholders": {
|
||||
@@ -1989,7 +1981,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
|
||||
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||
"@tracksCount": {
|
||||
"description": "Track count display",
|
||||
"placeholders": {
|
||||
@@ -2645,7 +2637,7 @@
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
},
|
||||
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
|
||||
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
|
||||
"@downloadedAlbumDeleteMessage": {
|
||||
"description": "Delete confirmation with count",
|
||||
"placeholders": {
|
||||
@@ -2684,7 +2676,7 @@
|
||||
"@downloadedAlbumTapToSelect": {
|
||||
"description": "Selection hint"
|
||||
},
|
||||
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
|
||||
"@downloadedAlbumDeleteCount": {
|
||||
"description": "Delete button text with count",
|
||||
"placeholders": {
|
||||
|
||||
+5
-13
@@ -85,7 +85,7 @@
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"historyTracksCount": "{count, plural, one {}=1{1 şarkı} other{{count} şarkı}}",
|
||||
"historyTracksCount": "{count, plural, one {1 şarkı} other {{count} şarkı}}",
|
||||
"@historyTracksCount": {
|
||||
"description": "Track count with plural form",
|
||||
"placeholders": {
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"historyAlbumsCount": "{count, plural, one {}=1{1 albüm} other{{count} albüm}}",
|
||||
"historyAlbumsCount": "{count, plural, one {1 albüm} other {{count} albüm}}",
|
||||
"@historyAlbumsCount": {
|
||||
"description": "Album count with plural form",
|
||||
"placeholders": {
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Bana bir kahve ısmarla",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Ko-fi üzerinden uygulamayı destekle",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "Uygulama",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
@@ -624,7 +616,7 @@
|
||||
"@albumTitle": {
|
||||
"description": "Album screen title"
|
||||
},
|
||||
"albumTracks": "{count, plural, one {}=1{1 şarkı} other{{count} şarkı}}",
|
||||
"albumTracks": "{count, plural, one {1 şarkı} other {{count} şarkı}}",
|
||||
"@albumTracks": {
|
||||
"description": "Album track count",
|
||||
"placeholders": {
|
||||
@@ -1136,7 +1128,7 @@
|
||||
"@dialogDeleteSelectedTitle": {
|
||||
"description": "Dialog title - delete selected items"
|
||||
},
|
||||
"dialogDeleteSelectedMessage": "{count} {count, plural, one {}=1{şarkıyı} other{şarkıyı}} geçmişten silmeye emin misiniz?\n\nBu işlem seçilenleri cihazınızdan da silecektir.",
|
||||
"dialogDeleteSelectedMessage": "{count} {count, plural, one {şarkıyı} other {şarkıyı}} geçmişten silmeye emin misiniz?\n\nBu işlem seçilenleri cihazınızdan da silecektir.",
|
||||
"@dialogDeleteSelectedMessage": {
|
||||
"description": "Dialog message - delete selected tracks",
|
||||
"placeholders": {
|
||||
@@ -1206,7 +1198,7 @@
|
||||
"@snackbarCredentialsCleared": {
|
||||
"description": "Snackbar - Spotify credentials removed"
|
||||
},
|
||||
"snackbarDeletedTracks": "{count} {count, plural, one {}=1{şarkı} other{şarkı}} silindi",
|
||||
"snackbarDeletedTracks": "{count} {count, plural, one {şarkı} other {şarkı}} silindi",
|
||||
"@snackbarDeletedTracks": {
|
||||
"description": "Snackbar - tracks deleted",
|
||||
"placeholders": {
|
||||
|
||||
@@ -548,14 +548,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
@@ -576,14 +576,6 @@
|
||||
"@aboutSupport": {
|
||||
"description": "Section for support/donation links"
|
||||
},
|
||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
||||
"@aboutBuyMeCoffee": {
|
||||
"description": "Donation link"
|
||||
},
|
||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
||||
"@aboutBuyMeCoffeeSubtitle": {
|
||||
"description": "Subtitle for donation"
|
||||
},
|
||||
"aboutApp": "App",
|
||||
"@aboutApp": {
|
||||
"description": "Section for app info"
|
||||
|
||||
+31
-18
@@ -11,51 +11,64 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await CoverCacheManager.initialize();
|
||||
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
|
||||
|
||||
await Future.wait([
|
||||
NotificationService().initialize(),
|
||||
ShareIntentService().initialize(),
|
||||
]);
|
||||
|
||||
_configureImageCache();
|
||||
|
||||
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
|
||||
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
const _EagerInitialization({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState();
|
||||
ConsumerState<_EagerInitialization> createState() =>
|
||||
_EagerInitializationState();
|
||||
}
|
||||
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAppServices();
|
||||
_initializeExtensions();
|
||||
ref.read(downloadHistoryProvider);
|
||||
}
|
||||
|
||||
Future<void> _initializeAppServices() async {
|
||||
try {
|
||||
await CoverCacheManager.initialize();
|
||||
await Future.wait([
|
||||
NotificationService().initialize(),
|
||||
ShareIntentService().initialize(),
|
||||
]);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to initialize app services: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeExtensions() async {
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.initialize(extensionsDir, dataDir);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to initialize extensions: $e');
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ class DownloadItem {
|
||||
final DownloadStatus status;
|
||||
final double progress;
|
||||
final double speedMBps;
|
||||
final int bytesReceived; // Bytes downloaded so far (for unknown size downloads)
|
||||
final String? filePath;
|
||||
final String? error;
|
||||
final DownloadErrorType? errorType;
|
||||
@@ -41,6 +42,7 @@ class DownloadItem {
|
||||
this.status = DownloadStatus.queued,
|
||||
this.progress = 0.0,
|
||||
this.speedMBps = 0.0,
|
||||
this.bytesReceived = 0,
|
||||
this.filePath,
|
||||
this.error,
|
||||
this.errorType,
|
||||
@@ -55,6 +57,7 @@ class DownloadItem {
|
||||
DownloadStatus? status,
|
||||
double? progress,
|
||||
double? speedMBps,
|
||||
int? bytesReceived,
|
||||
String? filePath,
|
||||
String? error,
|
||||
DownloadErrorType? errorType,
|
||||
@@ -68,6 +71,7 @@ class DownloadItem {
|
||||
status: status ?? this.status,
|
||||
progress: progress ?? this.progress,
|
||||
speedMBps: speedMBps ?? this.speedMBps,
|
||||
bytesReceived: bytesReceived ?? this.bytesReceived,
|
||||
filePath: filePath ?? this.filePath,
|
||||
error: error ?? this.error,
|
||||
errorType: errorType ?? this.errorType,
|
||||
|
||||
@@ -15,6 +15,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
||||
DownloadStatus.queued,
|
||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
||||
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
|
||||
filePath: json['filePath'] as String?,
|
||||
error: json['error'] as String?,
|
||||
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
||||
@@ -30,6 +31,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||
'status': _$DownloadStatusEnumMap[instance.status]!,
|
||||
'progress': instance.progress,
|
||||
'speedMBps': instance.speedMBps,
|
||||
'bytesReceived': instance.bytesReceived,
|
||||
'filePath': instance.filePath,
|
||||
'error': instance.error,
|
||||
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
||||
|
||||
@@ -8,6 +8,8 @@ class AppSettings {
|
||||
final String audioQuality;
|
||||
final String filenameFormat;
|
||||
final String downloadDirectory;
|
||||
final String storageMode; // 'app' or 'saf'
|
||||
final String downloadTreeUri; // SAF persistable tree URI
|
||||
final bool autoFallback;
|
||||
final bool embedLyrics;
|
||||
final bool maxQualityCover;
|
||||
@@ -17,6 +19,8 @@ class AppSettings {
|
||||
final String updateChannel;
|
||||
final bool hasSearchedBefore;
|
||||
final String folderOrganization;
|
||||
final bool useAlbumArtistForFolders;
|
||||
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
|
||||
final String historyViewMode;
|
||||
final String historyFilterMode;
|
||||
final bool askQualityBeforeDownload;
|
||||
@@ -32,15 +36,26 @@ class AppSettings {
|
||||
final bool showExtensionStore;
|
||||
final String locale;
|
||||
final String lyricsMode;
|
||||
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128'
|
||||
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
||||
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
||||
|
||||
// Local Library Settings
|
||||
final bool localLibraryEnabled; // Enable local library scanning
|
||||
final String localLibraryPath; // Path to scan for audio files
|
||||
final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||
|
||||
// Tutorial/Onboarding
|
||||
final bool hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
this.filenameFormat = '{title} - {artist}',
|
||||
this.downloadDirectory = '',
|
||||
this.storageMode = 'app',
|
||||
this.downloadTreeUri = '',
|
||||
this.autoFallback = true,
|
||||
this.embedLyrics = true,
|
||||
this.maxQualityCover = true,
|
||||
@@ -50,6 +65,8 @@ class AppSettings {
|
||||
this.updateChannel = 'stable',
|
||||
this.hasSearchedBefore = false,
|
||||
this.folderOrganization = 'none',
|
||||
this.useAlbumArtistForFolders = true,
|
||||
this.usePrimaryArtistOnly = false,
|
||||
this.historyViewMode = 'grid',
|
||||
this.historyFilterMode = 'all',
|
||||
this.askQualityBeforeDownload = true,
|
||||
@@ -68,6 +85,13 @@ class AppSettings {
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
this.useAllFilesAccess = false,
|
||||
this.autoExportFailedDownloads = false,
|
||||
this.downloadNetworkMode = 'any',
|
||||
// Local Library defaults
|
||||
this.localLibraryEnabled = false,
|
||||
this.localLibraryPath = '',
|
||||
this.localLibraryShowDuplicates = true,
|
||||
// Tutorial default
|
||||
this.hasCompletedTutorial = false,
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -75,6 +99,8 @@ class AppSettings {
|
||||
String? audioQuality,
|
||||
String? filenameFormat,
|
||||
String? downloadDirectory,
|
||||
String? storageMode,
|
||||
String? downloadTreeUri,
|
||||
bool? autoFallback,
|
||||
bool? embedLyrics,
|
||||
bool? maxQualityCover,
|
||||
@@ -84,6 +110,8 @@ class AppSettings {
|
||||
String? updateChannel,
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
bool? useAlbumArtistForFolders,
|
||||
bool? usePrimaryArtistOnly,
|
||||
String? historyViewMode,
|
||||
String? historyFilterMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
@@ -103,12 +131,21 @@ class AppSettings {
|
||||
String? tidalHighFormat,
|
||||
bool? useAllFilesAccess,
|
||||
bool? autoExportFailedDownloads,
|
||||
String? downloadNetworkMode,
|
||||
// Local Library
|
||||
bool? localLibraryEnabled,
|
||||
String? localLibraryPath,
|
||||
bool? localLibraryShowDuplicates,
|
||||
// Tutorial
|
||||
bool? hasCompletedTutorial,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
audioQuality: audioQuality ?? this.audioQuality,
|
||||
filenameFormat: filenameFormat ?? this.filenameFormat,
|
||||
downloadDirectory: downloadDirectory ?? this.downloadDirectory,
|
||||
storageMode: storageMode ?? this.storageMode,
|
||||
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
||||
autoFallback: autoFallback ?? this.autoFallback,
|
||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||
@@ -118,6 +155,10 @@ class AppSettings {
|
||||
updateChannel: updateChannel ?? this.updateChannel,
|
||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||
useAlbumArtistForFolders:
|
||||
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly:
|
||||
usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
|
||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||
@@ -136,6 +177,13 @@ class AppSettings {
|
||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
||||
// Local Library
|
||||
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
||||
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
||||
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||
// Tutorial
|
||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
|
||||
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
|
||||
downloadDirectory: json['downloadDirectory'] as String? ?? '',
|
||||
storageMode: json['storageMode'] as String? ?? 'app',
|
||||
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
|
||||
autoFallback: json['autoFallback'] as bool? ?? true,
|
||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||
@@ -20,6 +22,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
||||
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
@@ -41,6 +45,12 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||
autoExportFailedDownloads:
|
||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
|
||||
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
|
||||
localLibraryPath: json['localLibraryPath'] as String? ?? '',
|
||||
localLibraryShowDuplicates:
|
||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -49,6 +59,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'audioQuality': instance.audioQuality,
|
||||
'filenameFormat': instance.filenameFormat,
|
||||
'downloadDirectory': instance.downloadDirectory,
|
||||
'storageMode': instance.storageMode,
|
||||
'downloadTreeUri': instance.downloadTreeUri,
|
||||
'autoFallback': instance.autoFallback,
|
||||
'embedLyrics': instance.embedLyrics,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
@@ -58,6 +70,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'updateChannel': instance.updateChannel,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'historyFilterMode': instance.historyFilterMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
@@ -76,4 +90,9 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExploreProvider');
|
||||
|
||||
/// Represents an item in a Spotify home section
|
||||
class ExploreItem {
|
||||
final String id;
|
||||
final String uri;
|
||||
@@ -48,9 +49,22 @@ class ExploreItem {
|
||||
durationMs: json['duration_ms'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'uri': uri,
|
||||
'type': type,
|
||||
'name': name,
|
||||
'artists': artists,
|
||||
'description': description,
|
||||
'cover_url': coverUrl,
|
||||
'provider_id': providerId,
|
||||
'album_id': albumId,
|
||||
'album_name': albumName,
|
||||
'duration_ms': durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
/// Represents a section in Spotify home feed
|
||||
class ExploreSection {
|
||||
final String uri;
|
||||
final String title;
|
||||
@@ -77,9 +91,14 @@ class ExploreSection {
|
||||
isYTMusicQuickPicks: isQuickPicks,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'uri': uri,
|
||||
'title': title,
|
||||
'items': items.map((i) => i.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// State for explore/home feed
|
||||
class ExploreState {
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
@@ -114,7 +133,6 @@ class ExploreState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate greeting based on local device time
|
||||
String _getLocalGreeting() {
|
||||
final hour = DateTime.now().hour;
|
||||
if (hour >= 5 && hour < 12) {
|
||||
@@ -139,23 +157,72 @@ bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Provider for explore/home feed state
|
||||
class ExploreNotifier extends Notifier<ExploreState> {
|
||||
static const _cacheKey = 'explore_home_feed_cache';
|
||||
static const _cacheTsKey = 'explore_home_feed_ts';
|
||||
|
||||
@override
|
||||
ExploreState build() {
|
||||
_restoreFromCache();
|
||||
return const ExploreState();
|
||||
}
|
||||
|
||||
/// Restore cached home feed from SharedPreferences immediately on startup
|
||||
Future<void> _restoreFromCache() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cached = prefs.getString(_cacheKey);
|
||||
final cachedTs = prefs.getInt(_cacheTsKey);
|
||||
if (cached == null || cached.isEmpty) return;
|
||||
|
||||
final data = jsonDecode(cached) as Map<String, dynamic>;
|
||||
final sectionsData = data['sections'] as List<dynamic>? ?? [];
|
||||
final sections = sectionsData
|
||||
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
if (sections.isEmpty) return;
|
||||
|
||||
final lastFetched = cachedTs != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(cachedTs)
|
||||
: null;
|
||||
|
||||
_log.i('Restored ${sections.length} cached explore sections');
|
||||
state = ExploreState(
|
||||
greeting: _getLocalGreeting(),
|
||||
sections: sections,
|
||||
lastFetched: lastFetched,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to restore explore cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Save home feed to SharedPreferences for instant restore on next launch
|
||||
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final data = {
|
||||
'sections': sections.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
await prefs.setString(_cacheKey, jsonEncode(data));
|
||||
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
|
||||
_log.d('Saved ${sections.length} explore sections to cache');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save explore cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch home feed from spotify-web extension
|
||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||
|
||||
// Don't refetch if we have data and it's less than 5 minutes old
|
||||
// If we have cached content and it's fresh enough, skip network fetch
|
||||
if (!forceRefresh &&
|
||||
state.hasContent &&
|
||||
state.lastFetched != null &&
|
||||
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
|
||||
_log.d('Using cached home feed');
|
||||
_log.d('Using cached home feed (fresh enough)');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -164,14 +231,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
// Only show loading spinner if we have no cached content to display
|
||||
final showLoading = !state.hasContent;
|
||||
state = state.copyWith(isLoading: showLoading, error: null);
|
||||
|
||||
try {
|
||||
// Find any extension with homeFeed capability
|
||||
final extState = ref.read(extensionProvider);
|
||||
_log.d('Extensions count: ${extState.extensions.length}');
|
||||
|
||||
// Look for extensions with homeFeed capability (prefer spotify-web)
|
||||
Extension? targetExt;
|
||||
for (final extension in extState.extensions) {
|
||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||
@@ -225,14 +292,11 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
|
||||
_log.i('Fetched ${sections.length} sections');
|
||||
|
||||
// Debug: log first section items
|
||||
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
|
||||
final firstItem = sections.first.items.first;
|
||||
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
|
||||
}
|
||||
|
||||
// Always use local device time for greeting to avoid timezone issues
|
||||
// Extension greeting may use wrong timezone (UTC or Spotify account timezone)
|
||||
final localGreeting = _getLocalGreeting();
|
||||
_log.d('Greeting from extension: $greeting, using local: $localGreeting');
|
||||
|
||||
@@ -242,6 +306,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
sections: sections,
|
||||
lastFetched: DateTime.now(),
|
||||
);
|
||||
|
||||
// Save to disk cache for instant restore on next app launch
|
||||
_saveToCache(sections);
|
||||
} catch (e, stack) {
|
||||
_log.e('Error fetching home feed: $e', e, stack);
|
||||
state = state.copyWith(
|
||||
@@ -251,15 +318,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cached data
|
||||
void clear() {
|
||||
state = const ExploreState();
|
||||
}
|
||||
|
||||
/// Refresh home feed
|
||||
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
||||
}
|
||||
|
||||
|
||||
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
||||
return ExploreNotifier();
|
||||
});
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExtensionProvider');
|
||||
|
||||
const _metadataProviderPriorityKey = 'metadata_provider_priority';
|
||||
const _providerPriorityKey = 'provider_priority';
|
||||
|
||||
class Extension {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -452,7 +457,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
return const ExtensionState();
|
||||
}
|
||||
|
||||
/// Initialize the extension system
|
||||
Future<void> initialize(String extensionsDir, String dataDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
@@ -485,7 +489,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the list of installed extensions
|
||||
Future<void> refreshExtensions() async {
|
||||
try {
|
||||
final list = await PlatformBridge.getInstalledExtensions();
|
||||
@@ -493,7 +496,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
state = state.copyWith(extensions: extensions);
|
||||
_log.d('Loaded ${extensions.length} extensions');
|
||||
|
||||
// Log search behavior for extensions that have it
|
||||
for (final ext in extensions) {
|
||||
if (ext.searchBehavior != null) {
|
||||
_log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}');
|
||||
@@ -505,6 +507,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
@@ -550,7 +553,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Uninstall/remove an extension
|
||||
Future<bool> removeExtension(String extensionId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
@@ -567,6 +569,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||
@@ -603,7 +606,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get settings for an extension
|
||||
Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
||||
try {
|
||||
return await PlatformBridge.getExtensionSettings(extensionId);
|
||||
@@ -623,31 +625,67 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load provider priority order
|
||||
Future<void> loadProviderPriority() async {
|
||||
try {
|
||||
final priority = await PlatformBridge.getProviderPriority();
|
||||
// Load from SharedPreferences first (persisted)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedJson = prefs.getString(_providerPriorityKey);
|
||||
|
||||
List<String> priority;
|
||||
if (savedJson != null) {
|
||||
final saved = jsonDecode(savedJson) as List<dynamic>;
|
||||
priority = saved.map((e) => e as String).toList();
|
||||
_log.d('Loaded provider priority from prefs: $priority');
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
} else {
|
||||
// Fallback to Go backend default
|
||||
priority = await PlatformBridge.getProviderPriority();
|
||||
_log.d('Using default provider priority: $priority');
|
||||
}
|
||||
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load provider priority: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> setProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
// Save to SharedPreferences for persistence
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
|
||||
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
_log.d('Updated provider priority: $priority');
|
||||
_log.d('Saved provider priority: $priority');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set provider priority: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load metadata provider priority order
|
||||
Future<void> loadMetadataProviderPriority() async {
|
||||
try {
|
||||
final priority = await PlatformBridge.getMetadataProviderPriority();
|
||||
// Load from SharedPreferences first (persisted)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedJson = prefs.getString(_metadataProviderPriorityKey);
|
||||
|
||||
List<String> priority;
|
||||
if (savedJson != null) {
|
||||
final saved = jsonDecode(savedJson) as List<dynamic>;
|
||||
priority = saved.map((e) => e as String).toList();
|
||||
_log.d('Loaded metadata provider priority from prefs: $priority');
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
} else {
|
||||
// Fallback to Go backend default
|
||||
priority = await PlatformBridge.getMetadataProviderPriority();
|
||||
_log.d('Using default metadata provider priority: $priority');
|
||||
}
|
||||
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load metadata provider priority: $e');
|
||||
@@ -656,16 +694,20 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<void> setMetadataProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
// Save to SharedPreferences for persistence
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
|
||||
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
_log.d('Updated metadata provider priority: $priority');
|
||||
_log.d('Saved metadata provider priority: $priority');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set metadata provider priority: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleanup all extensions (call on app close)
|
||||
Future<void> cleanup() async {
|
||||
try {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
@@ -683,7 +725,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all enabled extensions
|
||||
List<Extension> get enabledExtensions {
|
||||
return state.extensions.where((ext) => ext.enabled).toList();
|
||||
}
|
||||
@@ -698,7 +739,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
return providers;
|
||||
}
|
||||
|
||||
/// Get all metadata providers (built-in + extensions)
|
||||
List<String> getAllMetadataProviders() {
|
||||
final providers = ['deezer', 'spotify'];
|
||||
for (final ext in state.extensions) {
|
||||
@@ -708,6 +748,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
List<Extension> get searchProviders {
|
||||
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('LocalLibrary');
|
||||
|
||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
||||
final _prefs = SharedPreferences.getInstance();
|
||||
|
||||
class LocalLibraryState {
|
||||
final List<LocalLibraryItem> items;
|
||||
final bool isScanning;
|
||||
final double scanProgress;
|
||||
final String? scanCurrentFile;
|
||||
final int scanTotalFiles;
|
||||
final int scannedFiles;
|
||||
final int scanErrorCount;
|
||||
final bool scanWasCancelled;
|
||||
final DateTime? lastScannedAt;
|
||||
final int excludedDownloadedCount;
|
||||
final Set<String> _trackKeySet;
|
||||
final Map<String, LocalLibraryItem> _byIsrc;
|
||||
final Map<String, LocalLibraryItem> _byTrackKey;
|
||||
|
||||
LocalLibraryState({
|
||||
this.items = const [],
|
||||
this.isScanning = false,
|
||||
this.scanProgress = 0,
|
||||
this.scanCurrentFile,
|
||||
this.scanTotalFiles = 0,
|
||||
this.scannedFiles = 0,
|
||||
this.scanErrorCount = 0,
|
||||
this.scanWasCancelled = false,
|
||||
this.lastScannedAt,
|
||||
this.excludedDownloadedCount = 0,
|
||||
Set<String>? trackKeySet,
|
||||
Map<String, LocalLibraryItem>? byIsrc,
|
||||
Map<String, LocalLibraryItem>? byTrackKey,
|
||||
}) : _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(),
|
||||
_byIsrc =
|
||||
byIsrc ??
|
||||
Map.fromEntries(
|
||||
items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
),
|
||||
_byTrackKey =
|
||||
byTrackKey ??
|
||||
Map.fromEntries(items.map((item) => MapEntry(item.matchKey, item)));
|
||||
|
||||
bool hasIsrc(String isrc) => _byIsrc.containsKey(isrc);
|
||||
|
||||
bool hasTrack(String trackName, String artistName) {
|
||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
return _trackKeySet.contains(key);
|
||||
}
|
||||
|
||||
LocalLibraryItem? getByIsrc(String isrc) => _byIsrc[isrc];
|
||||
|
||||
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
|
||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
return _byTrackKey[key];
|
||||
}
|
||||
|
||||
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
||||
if (isrc != null && isrc.isNotEmpty && hasIsrc(isrc)) {
|
||||
return true;
|
||||
}
|
||||
if (trackName != null && artistName != null) {
|
||||
return hasTrack(trackName, artistName);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
LocalLibraryState copyWith({
|
||||
List<LocalLibraryItem>? items,
|
||||
bool? isScanning,
|
||||
double? scanProgress,
|
||||
String? scanCurrentFile,
|
||||
int? scanTotalFiles,
|
||||
int? scannedFiles,
|
||||
int? scanErrorCount,
|
||||
bool? scanWasCancelled,
|
||||
DateTime? lastScannedAt,
|
||||
int? excludedDownloadedCount,
|
||||
}) {
|
||||
final nextItems = items ?? this.items;
|
||||
final keepDerivedIndex = identical(nextItems, this.items);
|
||||
|
||||
return LocalLibraryState(
|
||||
items: nextItems,
|
||||
isScanning: isScanning ?? this.isScanning,
|
||||
scanProgress: scanProgress ?? this.scanProgress,
|
||||
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
||||
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
|
||||
scannedFiles: scannedFiles ?? this.scannedFiles,
|
||||
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
|
||||
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
|
||||
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
||||
excludedDownloadedCount:
|
||||
excludedDownloadedCount ?? this.excludedDownloadedCount,
|
||||
trackKeySet: keepDerivedIndex ? _trackKeySet : null,
|
||||
byIsrc: keepDerivedIndex ? _byIsrc : null,
|
||||
byTrackKey: keepDerivedIndex ? _byTrackKey : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final LibraryDatabase _db = LibraryDatabase.instance;
|
||||
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
||||
static const _progressPollingInterval = Duration(milliseconds: 800);
|
||||
Timer? _progressTimer;
|
||||
bool _isLoaded = false;
|
||||
bool _scanCancelRequested = false;
|
||||
int _progressPollingErrorCount = 0;
|
||||
bool _isProgressPollingInFlight = false;
|
||||
|
||||
@override
|
||||
LocalLibraryState build() {
|
||||
ref.onDispose(() {
|
||||
_progressTimer?.cancel();
|
||||
});
|
||||
|
||||
Future.microtask(() async {
|
||||
await _loadFromDatabase();
|
||||
});
|
||||
return LocalLibraryState();
|
||||
}
|
||||
|
||||
Future<void> _loadFromDatabase() async {
|
||||
if (_isLoaded) return;
|
||||
_isLoaded = true;
|
||||
|
||||
try {
|
||||
final dbItemsFuture = _db.getAll();
|
||||
final prefsFuture = _prefs;
|
||||
final jsonList = await dbItemsFuture;
|
||||
final items = jsonList
|
||||
.map((e) => LocalLibraryItem.fromJson(e))
|
||||
.toList(growable: false);
|
||||
|
||||
DateTime? lastScannedAt;
|
||||
var excludedDownloadedCount = 0;
|
||||
try {
|
||||
final prefs = await prefsFuture;
|
||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
||||
}
|
||||
excludedDownloadedCount =
|
||||
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
|
||||
} catch (e) {
|
||||
_log.w('Failed to load lastScannedAt: $e');
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
lastScannedAt: lastScannedAt,
|
||||
excludedDownloadedCount: excludedDownloadedCount,
|
||||
);
|
||||
_log.i(
|
||||
'Loaded ${items.length} items from library database, lastScannedAt: '
|
||||
'$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount',
|
||||
);
|
||||
} catch (e, stack) {
|
||||
_log.e('Failed to load library from database: $e', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reloadFromStorage() async {
|
||||
_isLoaded = false;
|
||||
await _loadFromDatabase();
|
||||
}
|
||||
|
||||
Future<void> startScan(
|
||||
String folderPath, {
|
||||
bool forceFullScan = false,
|
||||
}) async {
|
||||
if (state.isScanning) {
|
||||
_log.w('Scan already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
_scanCancelRequested = false;
|
||||
_log.i(
|
||||
'Starting library scan: $folderPath (incremental: ${!forceFullScan})',
|
||||
);
|
||||
state = state.copyWith(
|
||||
isScanning: true,
|
||||
scanProgress: 0,
|
||||
scanCurrentFile: null,
|
||||
scanTotalFiles: 0,
|
||||
scannedFiles: 0,
|
||||
scanErrorCount: 0,
|
||||
scanWasCancelled: false,
|
||||
);
|
||||
|
||||
try {
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
final coverCacheDir = '${appSupportDir.path}/library_covers';
|
||||
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
|
||||
_log.i('Cover cache directory set to: $coverCacheDir');
|
||||
} catch (e) {
|
||||
_log.w('Failed to set cover cache directory: $e');
|
||||
}
|
||||
|
||||
_startProgressPolling();
|
||||
|
||||
try {
|
||||
final isSaf = folderPath.startsWith('content://');
|
||||
|
||||
// Get all file paths from download history to exclude them
|
||||
final downloadedPaths = await _historyDb.getAllFilePaths();
|
||||
_log.i(
|
||||
'Excluding ${downloadedPaths.length} downloaded files from library scan',
|
||||
);
|
||||
|
||||
if (forceFullScan) {
|
||||
// Full scan path - ignores existing data
|
||||
final results = isSaf
|
||||
? await PlatformBridge.scanSafTree(folderPath)
|
||||
: await PlatformBridge.scanLibraryFolder(folderPath);
|
||||
if (_scanCancelRequested) {
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
return;
|
||||
}
|
||||
|
||||
final items = <LocalLibraryItem>[];
|
||||
int skippedDownloads = 0;
|
||||
for (final json in results) {
|
||||
final filePath = json['filePath'] as String?;
|
||||
// Skip files that are already in download history
|
||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
||||
skippedDownloads++;
|
||||
continue;
|
||||
}
|
||||
final item = LocalLibraryItem.fromJson(json);
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
if (skippedDownloads > 0) {
|
||||
_log.i('Skipped $skippedDownloads files already in download history');
|
||||
}
|
||||
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save lastScannedAt: $e');
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
isScanning: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
scanWasCancelled: false,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
);
|
||||
|
||||
_log.i(
|
||||
'Full scan complete: ${items.length} tracks found, '
|
||||
'$skippedDownloads already in downloads',
|
||||
);
|
||||
} else {
|
||||
// Incremental scan path - only scans new/modified files
|
||||
final existingFiles = await _db.getFileModTimes();
|
||||
_log.i(
|
||||
'Incremental scan: ${existingFiles.length} existing files in database',
|
||||
);
|
||||
|
||||
final backfilledModTimes = await _backfillLegacyFileModTimes(
|
||||
isSaf: isSaf,
|
||||
existingFiles: existingFiles,
|
||||
);
|
||||
if (backfilledModTimes.isNotEmpty) {
|
||||
await _db.updateFileModTimes(backfilledModTimes);
|
||||
existingFiles.addAll(backfilledModTimes);
|
||||
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
|
||||
}
|
||||
|
||||
// Use appropriate incremental scan method based on SAF or not
|
||||
final Map<String, dynamic> result;
|
||||
if (isSaf) {
|
||||
result = await PlatformBridge.scanSafTreeIncremental(
|
||||
folderPath,
|
||||
existingFiles,
|
||||
);
|
||||
} else {
|
||||
result = await PlatformBridge.scanLibraryFolderIncremental(
|
||||
folderPath,
|
||||
existingFiles,
|
||||
);
|
||||
}
|
||||
|
||||
if (_scanCancelRequested) {
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse incremental scan result
|
||||
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
|
||||
final scannedList =
|
||||
(result['files'] as List<dynamic>?) ??
|
||||
(result['scanned'] as List<dynamic>?) ??
|
||||
[];
|
||||
final deletedPaths =
|
||||
(result['removedUris'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
(result['deletedPaths'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[];
|
||||
final skippedCount = result['skippedCount'] as int? ?? 0;
|
||||
final totalFiles = result['totalFiles'] as int? ?? 0;
|
||||
|
||||
_log.i(
|
||||
'Incremental result: ${scannedList.length} scanned, '
|
||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||
);
|
||||
|
||||
final currentByPath = <String, LocalLibraryItem>{
|
||||
for (final item in state.items) item.filePath: item,
|
||||
};
|
||||
|
||||
// Upsert new/modified items (excluding downloaded files)
|
||||
final updatedItems = <LocalLibraryItem>[];
|
||||
int skippedDownloads = 0;
|
||||
if (scannedList.isNotEmpty) {
|
||||
for (final json in scannedList) {
|
||||
final map = json as Map<String, dynamic>;
|
||||
final filePath = map['filePath'] as String?;
|
||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
||||
skippedDownloads++;
|
||||
continue;
|
||||
}
|
||||
final item = LocalLibraryItem.fromJson(map);
|
||||
updatedItems.add(item);
|
||||
currentByPath[item.filePath] = item;
|
||||
}
|
||||
if (updatedItems.isNotEmpty) {
|
||||
await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList());
|
||||
_log.i('Upserted ${updatedItems.length} items');
|
||||
}
|
||||
if (skippedDownloads > 0) {
|
||||
_log.i(
|
||||
'Skipped $skippedDownloads files already in download history',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removed items
|
||||
if (deletedPaths.isNotEmpty) {
|
||||
final deleteCount = await _db.deleteByPaths(deletedPaths);
|
||||
for (final path in deletedPaths) {
|
||||
currentByPath.remove(path);
|
||||
}
|
||||
_log.i('Deleted $deleteCount items from database');
|
||||
}
|
||||
|
||||
final items = currentByPath.values.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save lastScannedAt: $e');
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
isScanning: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
scanWasCancelled: false,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
);
|
||||
|
||||
_log.i(
|
||||
'Incremental scan complete: ${items.length} total tracks '
|
||||
'(${scannedList.length} new/updated, $skippedCount unchanged, '
|
||||
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
_log.e('Library scan failed: $e', e, stack);
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: false);
|
||||
} finally {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
}
|
||||
|
||||
void _startProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||
final nextProgress =
|
||||
(progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||
0.0,
|
||||
100.0,
|
||||
);
|
||||
final currentFile = progress['current_file'] as String?;
|
||||
final totalFiles = progress['total_files'] as int? ?? 0;
|
||||
final scannedFiles = progress['scanned_files'] as int? ?? 0;
|
||||
final errorCount = progress['error_count'] as int? ?? 0;
|
||||
|
||||
final shouldUpdateState =
|
||||
state.scanProgress != normalizedProgress ||
|
||||
state.scanCurrentFile != currentFile ||
|
||||
state.scanTotalFiles != totalFiles ||
|
||||
state.scannedFiles != scannedFiles ||
|
||||
state.scanErrorCount != errorCount;
|
||||
|
||||
if (shouldUpdateState) {
|
||||
state = state.copyWith(
|
||||
scanProgress: normalizedProgress,
|
||||
scanCurrentFile: currentFile,
|
||||
scanTotalFiles: totalFiles,
|
||||
scannedFiles: scannedFiles,
|
||||
scanErrorCount: errorCount,
|
||||
);
|
||||
}
|
||||
|
||||
if (progress['is_complete'] == true) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Library scan progress polling failed: $e');
|
||||
}
|
||||
} finally {
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressPollingErrorCount = 0;
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
|
||||
Future<void> cancelScan() async {
|
||||
if (!state.isScanning) return;
|
||||
|
||||
_log.i('Cancelling library scan');
|
||||
_scanCancelRequested = true;
|
||||
await PlatformBridge.cancelLibraryScan();
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
Future<int> cleanupMissingFiles() async {
|
||||
final removed = await _db.cleanupMissingFiles();
|
||||
if (removed > 0) {
|
||||
await reloadFromStorage();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
Future<void> clearLibrary() async {
|
||||
await _db.clearAll();
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_lastScannedAtKey);
|
||||
await prefs.remove(_excludedDownloadedCountKey);
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear lastScannedAt: $e');
|
||||
}
|
||||
|
||||
state = LocalLibraryState();
|
||||
_log.i('Library cleared');
|
||||
}
|
||||
|
||||
Future<void> removeItem(String id) async {
|
||||
await _db.delete(id);
|
||||
state = state.copyWith(
|
||||
items: state.items.where((item) => item.id != id).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
||||
return state.existsInLibrary(
|
||||
isrc: isrc,
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
);
|
||||
}
|
||||
|
||||
LocalLibraryItem? getByIsrc(String isrc) {
|
||||
return state.getByIsrc(isrc);
|
||||
}
|
||||
|
||||
LocalLibraryItem? findExisting({
|
||||
String? isrc,
|
||||
String? trackName,
|
||||
String? artistName,
|
||||
}) {
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = state.getByIsrc(isrc);
|
||||
if (byIsrc != null) return byIsrc;
|
||||
}
|
||||
if (trackName != null && artistName != null) {
|
||||
return state.findByTrackAndArtist(trackName, artistName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<LocalLibraryItem>> search(String query) async {
|
||||
if (query.isEmpty) return [];
|
||||
|
||||
final results = await _db.search(query);
|
||||
return results.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
Future<int> getCount() async {
|
||||
return await _db.getCount();
|
||||
}
|
||||
|
||||
int _compareLibraryItems(LocalLibraryItem a, LocalLibraryItem b) {
|
||||
final artistA = (a.albumArtist ?? a.artistName).toLowerCase();
|
||||
final artistB = (b.albumArtist ?? b.artistName).toLowerCase();
|
||||
final artistCompare = artistA.compareTo(artistB);
|
||||
if (artistCompare != 0) return artistCompare;
|
||||
|
||||
final albumCompare = a.albumName.toLowerCase().compareTo(
|
||||
b.albumName.toLowerCase(),
|
||||
);
|
||||
if (albumCompare != 0) return albumCompare;
|
||||
|
||||
final discCompare = (a.discNumber ?? 0).compareTo(b.discNumber ?? 0);
|
||||
if (discCompare != 0) return discCompare;
|
||||
|
||||
return (a.trackNumber ?? 0).compareTo(b.trackNumber ?? 0);
|
||||
}
|
||||
|
||||
Future<Map<String, int>> _backfillLegacyFileModTimes({
|
||||
required bool isSaf,
|
||||
required Map<String, int> existingFiles,
|
||||
}) async {
|
||||
final legacyPaths = existingFiles.entries
|
||||
.where((entry) => entry.value <= 0)
|
||||
.map((entry) => entry.key)
|
||||
.toList();
|
||||
if (legacyPaths.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
if (isSaf) {
|
||||
final uris = legacyPaths
|
||||
.where((path) => path.startsWith('content://'))
|
||||
.toList();
|
||||
if (uris.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
const chunkSize = 500;
|
||||
final backfilled = <String, int>{};
|
||||
try {
|
||||
for (var i = 0; i < uris.length; i += chunkSize) {
|
||||
if (_scanCancelRequested) {
|
||||
break;
|
||||
}
|
||||
final end = (i + chunkSize < uris.length)
|
||||
? i + chunkSize
|
||||
: uris.length;
|
||||
final chunk = uris.sublist(i, end);
|
||||
final chunkResult = await PlatformBridge.getSafFileModTimes(chunk);
|
||||
backfilled.addAll(chunkResult);
|
||||
}
|
||||
return backfilled;
|
||||
} catch (e) {
|
||||
_log.w('Failed to backfill SAF mod times: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
final paths = legacyPaths
|
||||
.where((path) => !path.startsWith('content://'))
|
||||
.toList(growable: false);
|
||||
const chunkSize = 24;
|
||||
final backfilled = <String, int>{};
|
||||
|
||||
for (var i = 0; i < paths.length; i += chunkSize) {
|
||||
if (_scanCancelRequested) {
|
||||
break;
|
||||
}
|
||||
final end = (i + chunkSize < paths.length) ? i + chunkSize : paths.length;
|
||||
final chunk = paths.sublist(i, end);
|
||||
final chunkEntries = await Future.wait<MapEntry<String, int>?>(
|
||||
chunk.map((path) async {
|
||||
try {
|
||||
final stat = await File(path).stat();
|
||||
if (stat.type == FileSystemEntityType.file) {
|
||||
return MapEntry(path, stat.modified.millisecondsSinceEpoch);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
for (final entry in chunkEntries) {
|
||||
if (entry != null) {
|
||||
backfilled[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return backfilled;
|
||||
}
|
||||
}
|
||||
|
||||
final localLibraryProvider =
|
||||
NotifierProvider<LocalLibraryNotifier, LocalLibraryState>(
|
||||
LocalLibraryNotifier.new,
|
||||
);
|
||||
@@ -1,16 +1,23 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 1;
|
||||
const _currentMigrationVersion = 2;
|
||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
final _log = AppLogger('SettingsProvider');
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
bool _isSavingSettings = false;
|
||||
bool _saveQueued = false;
|
||||
String? _pendingSettingsJson;
|
||||
|
||||
@override
|
||||
AppSettings build() {
|
||||
@@ -23,37 +30,103 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final json = prefs.getString(_settingsKey);
|
||||
if (json != null) {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
|
||||
|
||||
await _runMigrations(prefs);
|
||||
|
||||
_applySpotifyCredentials();
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
}
|
||||
|
||||
await _loadSpotifyClientSecret(prefs);
|
||||
|
||||
_applySpotifyCredentials();
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
}
|
||||
|
||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||
|
||||
|
||||
if (lastMigration < 1) {
|
||||
if (!state.useCustomSpotifyCredentials) {
|
||||
state = state.copyWith(metadataSource: 'deezer');
|
||||
await _saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (lastMigration < _currentMigrationVersion) {
|
||||
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
|
||||
state = state.copyWith(storageMode: 'saf');
|
||||
}
|
||||
// Migration 2: existing users who already completed setup should skip tutorial
|
||||
if (!state.isFirstLaunch && !state.hasCompletedTutorial) {
|
||||
state = state.copyWith(hasCompletedTutorial: true);
|
||||
}
|
||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||
await _saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||
final settingsToSave = state.copyWith(spotifyClientSecret: '');
|
||||
_pendingSettingsJson = jsonEncode(settingsToSave.toJson());
|
||||
|
||||
if (_isSavingSettings) {
|
||||
_saveQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_isSavingSettings = true;
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
do {
|
||||
final jsonToWrite = _pendingSettingsJson;
|
||||
_saveQueued = false;
|
||||
if (jsonToWrite != null) {
|
||||
await prefs.setString(_settingsKey, jsonToWrite);
|
||||
}
|
||||
} while (_saveQueued);
|
||||
} catch (e) {
|
||||
_log.e('Failed to save settings: $e');
|
||||
} finally {
|
||||
_isSavingSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
||||
final storedSecret = await _secureStorage.read(
|
||||
key: _spotifyClientSecretKey,
|
||||
);
|
||||
final prefsSecret = state.spotifyClientSecret;
|
||||
|
||||
if ((storedSecret == null || storedSecret.isEmpty) &&
|
||||
prefsSecret.isNotEmpty) {
|
||||
await _secureStorage.write(
|
||||
key: _spotifyClientSecretKey,
|
||||
value: prefsSecret,
|
||||
);
|
||||
}
|
||||
|
||||
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
|
||||
? storedSecret
|
||||
: (prefsSecret.isNotEmpty ? prefsSecret : '');
|
||||
|
||||
if (effectiveSecret != state.spotifyClientSecret) {
|
||||
state = state.copyWith(spotifyClientSecret: effectiveSecret);
|
||||
}
|
||||
|
||||
if (prefsSecret.isNotEmpty) {
|
||||
await _saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _storeSpotifyClientSecret(String secret) async {
|
||||
if (secret.isEmpty) {
|
||||
await _secureStorage.delete(key: _spotifyClientSecretKey);
|
||||
} else {
|
||||
await _secureStorage.write(key: _spotifyClientSecretKey, value: secret);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applySpotifyCredentials() async {
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
state.spotifyClientSecret.isNotEmpty) {
|
||||
await PlatformBridge.setSpotifyCredentials(
|
||||
state.spotifyClientId,
|
||||
@@ -82,6 +155,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setStorageMode(String mode) {
|
||||
final normalized = mode == 'saf' ? 'saf' : 'app';
|
||||
state = state.copyWith(storageMode: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setDownloadTreeUri(String uri, {String? displayName}) {
|
||||
final nextDisplay = displayName ?? state.downloadDirectory;
|
||||
state = state.copyWith(
|
||||
downloadTreeUri: uri,
|
||||
storageMode: uri.isNotEmpty ? 'saf' : state.storageMode,
|
||||
downloadDirectory: nextDisplay,
|
||||
);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoFallback(bool enabled) {
|
||||
state = state.copyWith(autoFallback: enabled);
|
||||
_saveSettings();
|
||||
@@ -110,7 +199,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void setConcurrentDownloads(int count) {
|
||||
final clamped = count.clamp(1, 3);
|
||||
final clamped = count.clamp(1, 5);
|
||||
state = state.copyWith(concurrentDownloads: clamped);
|
||||
_saveSettings();
|
||||
}
|
||||
@@ -137,6 +226,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAlbumArtistForFolders(bool enabled) {
|
||||
state = state.copyWith(useAlbumArtistForFolders: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUsePrimaryArtistOnly(bool enabled) {
|
||||
state = state.copyWith(usePrimaryArtistOnly: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHistoryViewMode(String mode) {
|
||||
state = state.copyWith(historyViewMode: mode);
|
||||
_saveSettings();
|
||||
@@ -157,25 +256,28 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSpotifyClientSecret(String clientSecret) {
|
||||
Future<void> setSpotifyClientSecret(String clientSecret) async {
|
||||
state = state.copyWith(spotifyClientSecret: clientSecret);
|
||||
await _storeSpotifyClientSecret(clientSecret);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSpotifyCredentials(String clientId, String clientSecret) {
|
||||
Future<void> setSpotifyCredentials(
|
||||
String clientId,
|
||||
String clientSecret,
|
||||
) async {
|
||||
state = state.copyWith(
|
||||
spotifyClientId: clientId,
|
||||
spotifyClientSecret: clientSecret,
|
||||
);
|
||||
await _storeSpotifyClientSecret(clientSecret);
|
||||
_saveSettings();
|
||||
_applySpotifyCredentials();
|
||||
}
|
||||
|
||||
void clearSpotifyCredentials() {
|
||||
state = state.copyWith(
|
||||
spotifyClientId: '',
|
||||
spotifyClientSecret: '',
|
||||
);
|
||||
Future<void> clearSpotifyCredentials() async {
|
||||
state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
|
||||
await _storeSpotifyClientSecret('');
|
||||
_saveSettings();
|
||||
_applySpotifyCredentials();
|
||||
}
|
||||
@@ -236,7 +338,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAllFilesAccess(bool enabled) {
|
||||
void setUseAllFilesAccess(bool enabled) {
|
||||
state = state.copyWith(useAllFilesAccess: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
@@ -245,6 +347,31 @@ void setUseAllFilesAccess(bool enabled) {
|
||||
state = state.copyWith(autoExportFailedDownloads: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setDownloadNetworkMode(String mode) {
|
||||
state = state.copyWith(downloadNetworkMode: mode);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryEnabled(bool enabled) {
|
||||
state = state.copyWith(localLibraryEnabled: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryPath(String path) {
|
||||
state = state.copyWith(localLibraryPath: path);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryShowDuplicates(bool show) {
|
||||
state = state.copyWith(localLibraryShowDuplicates: show);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setTutorialComplete() {
|
||||
state = state.copyWith(hasCompletedTutorial: true);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -7,8 +7,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
final _log = AppLogger('StoreProvider');
|
||||
final RegExp _leadingVersionPrefix = RegExp(r'^v');
|
||||
|
||||
/// Compare two semantic version strings
|
||||
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||
int compareVersions(String v1, String v2) {
|
||||
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||
@@ -25,8 +23,8 @@ int compareVersions(String v1, String v2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Extension categories
|
||||
class StoreCategory {
|
||||
|
||||
static const String metadata = 'metadata';
|
||||
static const String download = 'download';
|
||||
static const String utility = 'utility';
|
||||
@@ -111,13 +109,13 @@ class StoreExtension {
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this extension requires a higher app version than current
|
||||
bool get requiresNewerApp {
|
||||
if (minAppVersion == null || minAppVersion!.isEmpty) return false;
|
||||
return compareVersions(minAppVersion!, AppInfo.version) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StoreState {
|
||||
final List<StoreExtension> extensions;
|
||||
final String? selectedCategory;
|
||||
@@ -164,7 +162,6 @@ class StoreState {
|
||||
);
|
||||
}
|
||||
|
||||
/// Get filtered extensions based on category and search
|
||||
List<StoreExtension> get filteredExtensions {
|
||||
var result = extensions;
|
||||
|
||||
@@ -186,13 +183,11 @@ class StoreState {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Count of extensions with updates available
|
||||
int get updatesAvailableCount {
|
||||
return extensions.where((e) => e.hasUpdate).length;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for managing extension store
|
||||
class StoreNotifier extends Notifier<StoreState> {
|
||||
@override
|
||||
StoreState build() {
|
||||
@@ -215,7 +210,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh extensions from store
|
||||
Future<void> refresh({bool forceRefresh = false}) async {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
@@ -240,7 +234,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set search query
|
||||
void setSearchQuery(String query) {
|
||||
state = state.copyWith(searchQuery: query);
|
||||
}
|
||||
@@ -249,7 +242,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||
}
|
||||
|
||||
/// Download and install extension
|
||||
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
|
||||
@@ -275,6 +267,7 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
|
||||
|
||||
@@ -3,23 +3,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/models/theme_settings.dart';
|
||||
|
||||
/// Provider for theme settings state management
|
||||
final themeProvider = NotifierProvider<ThemeNotifier, ThemeSettings>(() {
|
||||
return ThemeNotifier();
|
||||
});
|
||||
|
||||
/// Notifier for managing theme settings with persistence
|
||||
class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
@override
|
||||
ThemeSettings build() {
|
||||
// Load settings asynchronously on first access
|
||||
_loadFromStorage();
|
||||
return const ThemeSettings();
|
||||
}
|
||||
|
||||
/// Load theme settings from SharedPreferences
|
||||
Future<void> _loadFromStorage() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
@@ -39,7 +35,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save current settings to SharedPreferences
|
||||
Future<void> _saveToStorage() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
@@ -52,13 +47,11 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set theme mode (light, dark, or system)
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
state = state.copyWith(themeMode: mode);
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
/// Enable or disable dynamic color from wallpaper
|
||||
Future<void> setUseDynamicColor(bool value) async {
|
||||
state = state.copyWith(useDynamicColor: value);
|
||||
await _saveToStorage();
|
||||
@@ -70,19 +63,16 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
/// Set seed color from int value
|
||||
Future<void> setSeedColorValue(int colorValue) async {
|
||||
state = state.copyWith(seedColorValue: colorValue);
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
/// Enable or disable AMOLED mode (pure black background)
|
||||
Future<void> setUseAmoled(bool value) async {
|
||||
state = state.copyWith(useAmoled: value);
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
/// Helper to convert string to ThemeMode
|
||||
ThemeMode _themeModeFromString(String? value) {
|
||||
if (value == null) return ThemeMode.system;
|
||||
return ThemeMode.values.firstWhere(
|
||||
@@ -91,3 +81,4 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,10 @@ class TrackState {
|
||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
||||
final bool hasSearchText; // For back button handling
|
||||
final bool isShowingRecentAccess; // For recent access mode
|
||||
final String? searchExtensionId; // Extension ID used for current search results
|
||||
final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||
final String?
|
||||
searchExtensionId; // Extension ID used for current search results
|
||||
final String?
|
||||
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -52,7 +54,12 @@ class TrackState {
|
||||
this.selectedSearchFilter,
|
||||
});
|
||||
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty) || (searchAlbums != null && searchAlbums!.isNotEmpty) || (searchPlaylists != null && searchPlaylists!.isNotEmpty);
|
||||
bool get hasContent =>
|
||||
tracks.isNotEmpty ||
|
||||
artistAlbums != null ||
|
||||
(searchArtists != null && searchArtists!.isNotEmpty) ||
|
||||
(searchAlbums != null && searchAlbums!.isNotEmpty) ||
|
||||
(searchPlaylists != null && searchPlaylists!.isNotEmpty);
|
||||
|
||||
TrackState copyWith({
|
||||
List<Track>? tracks,
|
||||
@@ -95,9 +102,12 @@ class TrackState {
|
||||
searchAlbums: searchAlbums ?? this.searchAlbums,
|
||||
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
|
||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||
isShowingRecentAccess:
|
||||
isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||
searchExtensionId: searchExtensionId,
|
||||
selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||
selectedSearchFilter: clearSelectedSearchFilter
|
||||
? null
|
||||
: (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -178,6 +188,7 @@ class SearchPlaylist {
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
int _currentRequestId = 0;
|
||||
static const int _maxPreWarmTracksPerRequest = 80;
|
||||
|
||||
@override
|
||||
TrackState build() {
|
||||
@@ -193,19 +204,54 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
// Step 1: Check for extension URL handlers first (handles YT Music, etc.)
|
||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
// Retry logic for extension URL handlers (up to 3 attempts)
|
||||
Map<String, dynamic>? result;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
// Check if we got valid data
|
||||
if (result != null &&
|
||||
result['type'] == 'track' &&
|
||||
result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final name = trackData['name']?.toString() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
break;
|
||||
}
|
||||
} else if (result != null &&
|
||||
(result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||
break;
|
||||
} else if (result != null && result['type'] == 'artist') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt < 3) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
final type = result['type'] as String?;
|
||||
final extensionId = result['extension_id'] as String?;
|
||||
|
||||
|
||||
if (type == 'track' && result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||
|
||||
if (track.name.isEmpty) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: 'Failed to load track metadata from extension',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
@@ -213,15 +259,27 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) {
|
||||
} else if ((type == 'album' || type == 'playlist') &&
|
||||
result['tracks'] != null) {
|
||||
final trackList = result['tracks'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId: result['album']?['id'] as String?,
|
||||
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
|
||||
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
||||
albumName:
|
||||
result['name'] as String? ??
|
||||
result['album']?['name'] as String?,
|
||||
playlistName: type == 'playlist'
|
||||
? result['name'] as String?
|
||||
: null,
|
||||
coverUrl: result['cover_url'] as String?,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
@@ -229,17 +287,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'artist' && result['artist'] != null) {
|
||||
final artistData = result['artist'] as Map<String, dynamic>;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
|
||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final topTracksList =
|
||||
artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
|
||||
coverUrl:
|
||||
artistData['image_url'] as String? ??
|
||||
artistData['images'] as String?,
|
||||
headerImageUrl: artistData['header_image'] as String?,
|
||||
monthlyListeners: artistData['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
@@ -250,21 +320,162 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Step 2: Try Deezer URL parsing
|
||||
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
|
||||
_log.i('Detected Deezer URL, parsing...');
|
||||
final parsed = await PlatformBridge.parseDeezerUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
final id = parsed['id'] as String;
|
||||
|
||||
final metadata = await PlatformBridge.getDeezerMetadata(type, id);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (type == 'track') {
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
);
|
||||
} else if (type == 'album') {
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId: id,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
final playlistInfo =
|
||||
metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
playlistName: playlistInfo['name'] as String?,
|
||||
coverUrl: playlistInfo['images'] as String?,
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'artist') {
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Try Tidal URL parsing
|
||||
if (url.contains('tidal.com')) {
|
||||
_log.i('Detected Tidal URL, parsing...');
|
||||
final parsed = await PlatformBridge.parseTidalUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
final id = parsed['id'] as String;
|
||||
|
||||
_log.i('Tidal URL parsed: type=$type, id=$id');
|
||||
|
||||
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
|
||||
if (type == 'track') {
|
||||
try {
|
||||
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
|
||||
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(
|
||||
url,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final spotifyUrl = conversion['spotify_url'] as String?;
|
||||
final deezerUrl = conversion['deezer_url'] as String?;
|
||||
|
||||
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
|
||||
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
|
||||
final metadata =
|
||||
await PlatformBridge.getSpotifyMetadataWithFallback(
|
||||
spotifyUrl,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
);
|
||||
return;
|
||||
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
|
||||
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
|
||||
final deezerParsed = await PlatformBridge.parseDeezerUrl(
|
||||
deezerUrl,
|
||||
);
|
||||
final metadata = await PlatformBridge.getDeezerMetadata(
|
||||
'track',
|
||||
deezerParsed['id'] as String,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to convert Tidal URL via SongLink: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// For album/artist/playlist, not yet supported
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error:
|
||||
'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Fall back to Spotify parsing
|
||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
|
||||
Map<String, dynamic> metadata;
|
||||
|
||||
|
||||
try {
|
||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (type == 'track') {
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
@@ -277,7 +488,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'album') {
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -289,7 +502,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'playlist') {
|
||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
@@ -301,7 +516,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'artist') {
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
@@ -313,17 +530,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(String query, {String? metadataSource, String? filterOverride}) async {
|
||||
Future<void> search(
|
||||
String query, {
|
||||
String? metadataSource,
|
||||
String? filterOverride,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
|
||||
// Preserve selected filter during loading
|
||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
|
||||
try {
|
||||
final settings = ref.read(settingsProvider);
|
||||
@@ -339,20 +568,23 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
searchProvider.isNotEmpty;
|
||||
|
||||
final source = metadataSource ?? 'deezer';
|
||||
|
||||
|
||||
_log.i(
|
||||
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
|
||||
);
|
||||
|
||||
|
||||
Map<String, dynamic> results;
|
||||
List<Track> extensionTracks = [];
|
||||
|
||||
|
||||
if (useExtensions) {
|
||||
try {
|
||||
_log.d('Calling extension search API...');
|
||||
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
||||
final extResults = await PlatformBridge.searchTracksWithExtensions(
|
||||
query,
|
||||
limit: 20,
|
||||
);
|
||||
_log.i('Extensions returned ${extResults.length} tracks');
|
||||
|
||||
|
||||
for (final t in extResults) {
|
||||
try {
|
||||
extensionTracks.add(_parseSearchTrack(t));
|
||||
@@ -364,37 +596,52 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.w('Extension search failed, falling back to built-in: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (source == 'deezer') {
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 2, filter: currentFilter);
|
||||
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums');
|
||||
results = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
_log.i(
|
||||
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||
);
|
||||
} else {
|
||||
_log.d('Calling Spotify search API...');
|
||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 2);
|
||||
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||
results = await PlatformBridge.searchSpotifyAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
);
|
||||
_log.i(
|
||||
'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
_log.w('Search request cancelled (requestId=$requestId)');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
final albumList = results['albums'] as List<dynamic>? ?? [];
|
||||
|
||||
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums');
|
||||
|
||||
|
||||
_log.d(
|
||||
'Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
|
||||
);
|
||||
|
||||
final tracks = <Track>[];
|
||||
|
||||
|
||||
tracks.addAll(extensionTracks);
|
||||
|
||||
|
||||
final existingIsrcs = extensionTracks
|
||||
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
||||
.map((t) => t.isrc!)
|
||||
.toSet();
|
||||
|
||||
|
||||
for (int i = 0; i < trackList.length; i++) {
|
||||
final t = trackList[i];
|
||||
try {
|
||||
@@ -411,7 +658,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse track[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final artists = <SearchArtist>[];
|
||||
for (int i = 0; i < artistList.length; i++) {
|
||||
final a = artistList[i];
|
||||
@@ -425,7 +672,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse artist[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final albums = <SearchAlbum>[];
|
||||
for (int i = 0; i < albumList.length; i++) {
|
||||
final a = albumList[i];
|
||||
@@ -439,7 +686,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse album[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final playlistList = results['playlists'] as List<dynamic>? ?? [];
|
||||
final playlists = <SearchPlaylist>[];
|
||||
for (int i = 0; i < playlistList.length; i++) {
|
||||
@@ -454,9 +701,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse playlist[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully');
|
||||
|
||||
|
||||
_log.i(
|
||||
'Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully',
|
||||
);
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: artists,
|
||||
@@ -469,31 +718,45 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.e('Search failed: $e', e, stackTrace);
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
||||
Future<void> customSearch(
|
||||
String extensionId,
|
||||
String query, {
|
||||
Map<String, dynamic>? options,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve filter during loading
|
||||
);
|
||||
|
||||
try {
|
||||
_log.i('Custom search started: extension=$extensionId, query="$query"');
|
||||
|
||||
final results = await PlatformBridge.customSearchWithExtension(extensionId, query, options: options);
|
||||
|
||||
|
||||
final results = await PlatformBridge.customSearchWithExtension(
|
||||
extensionId,
|
||||
query,
|
||||
options: options,
|
||||
);
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
_log.w('Custom search request cancelled (requestId=$requestId)');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_log.i('Custom search returned ${results.length} tracks');
|
||||
|
||||
|
||||
final tracks = <Track>[];
|
||||
for (int i = 0; i < results.length; i++) {
|
||||
final t = results[i];
|
||||
@@ -503,21 +766,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse custom search track[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)');
|
||||
|
||||
|
||||
_log.i(
|
||||
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
|
||||
);
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: [],
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve selected filter
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.e('Custom search failed: $e', e, stackTrace);
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,7 +798,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
if (track.isrc == null || track.isrc!.isEmpty) return;
|
||||
|
||||
try {
|
||||
final availability = await PlatformBridge.checkAvailability(track.id, track.isrc!);
|
||||
final availability = await PlatformBridge.checkAvailability(
|
||||
track.id,
|
||||
track.isrc!,
|
||||
);
|
||||
final updatedTrack = Track(
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
@@ -556,7 +829,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final tracks = List<Track>.from(state.tracks);
|
||||
tracks[index] = updatedTrack;
|
||||
state = state.copyWith(tracks: tracks);
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
// Silently ignore update failures - track may have been removed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,11 +854,14 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
state = state.copyWith(hasSearchText: hasText);
|
||||
}
|
||||
|
||||
|
||||
void setShowingRecentAccess(bool showing) {
|
||||
if (state.isShowingRecentAccess == showing) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isShowingRecentAccess: showing);
|
||||
}
|
||||
|
||||
|
||||
/// Set tracks from a collection (album/playlist) opened from search results
|
||||
void setTracksFromCollection({
|
||||
required List<Track> tracks,
|
||||
@@ -626,9 +903,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (durationValue is double) {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
|
||||
final itemType = data['item_type']?.toString();
|
||||
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
@@ -641,7 +918,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||
source:
|
||||
source ??
|
||||
data['source']?.toString() ??
|
||||
data['provider_id']?.toString(),
|
||||
albumType: data['album_type']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
@@ -693,16 +973,25 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
|
||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||
if (tracksWithIsrc.isEmpty) return;
|
||||
|
||||
final cacheRequests = tracksWithIsrc.map((t) => {
|
||||
'isrc': t.isrc!,
|
||||
'track_name': t.name,
|
||||
'artist_name': t.artistName,
|
||||
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
||||
'service': 'tidal',
|
||||
}).toList();
|
||||
if (tracks.isEmpty) return;
|
||||
final cacheRequests = <Map<String, String>>[];
|
||||
for (final track in tracks) {
|
||||
final isrc = track.isrc;
|
||||
if (isrc == null || isrc.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
cacheRequests.add({
|
||||
'isrc': isrc,
|
||||
'track_name': track.name,
|
||||
'artist_name': track.artistName,
|
||||
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
|
||||
'service': 'tidal',
|
||||
});
|
||||
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cacheRequests.isEmpty) return;
|
||||
|
||||
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
|
||||
}
|
||||
|
||||
+465
-165
@@ -1,8 +1,8 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
@@ -10,10 +10,13 @@ import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
show ExtensionArtistScreen;
|
||||
|
||||
class _AlbumCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
@@ -44,10 +47,10 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumId;
|
||||
final String albumName;
|
||||
final String? coverUrl;
|
||||
final List<Track>? tracks; // Optional - will fetch if null
|
||||
final String? extensionId; // If from extension
|
||||
final String? artistId; // Artist ID for navigation
|
||||
final String? artistName; // Artist name for navigation
|
||||
final List<Track>? tracks;
|
||||
final String? extensionId;
|
||||
final String? artistId;
|
||||
final String? artistName;
|
||||
|
||||
const AlbumScreen({
|
||||
super.key,
|
||||
@@ -68,7 +71,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
List<Track>? _tracks;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
String? _artistId;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
@@ -76,35 +78,35 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Use extensionId if available, otherwise detect from albumId prefix
|
||||
final providerId = widget.extensionId ??
|
||||
final providerId =
|
||||
widget.extensionId ??
|
||||
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
|
||||
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
||||
id: widget.albumId,
|
||||
name: widget.albumName,
|
||||
artistName: widget.tracks?.firstOrNull?.artistName,
|
||||
imageUrl: widget.coverUrl,
|
||||
providerId: providerId,
|
||||
);
|
||||
ref
|
||||
.read(recentAccessProvider.notifier)
|
||||
.recordAlbumAccess(
|
||||
id: widget.albumId,
|
||||
name: widget.albumName,
|
||||
artistName: widget.tracks?.firstOrNull?.artistName,
|
||||
imageUrl: widget.coverUrl,
|
||||
providerId: providerId,
|
||||
);
|
||||
});
|
||||
|
||||
// Use provided tracks if not empty, otherwise try cache
|
||||
|
||||
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
|
||||
_tracks = widget.tracks;
|
||||
} else {
|
||||
_tracks = _AlbumCache.get(widget.albumId);
|
||||
}
|
||||
_artistId = widget.artistId; // Use provided artist ID if available
|
||||
|
||||
_artistId = widget.artistId;
|
||||
|
||||
if (_tracks == null || _tracks!.isEmpty) {
|
||||
_fetchTracks();
|
||||
}
|
||||
|
||||
_extractDominantColor();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -121,54 +123,47 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractDominantColor() async {
|
||||
if (widget.coverUrl == null) return;
|
||||
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
||||
if (mounted && color != null) {
|
||||
setState(() => _dominantColor = color);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatReleaseDate(String date) {
|
||||
// Handle formats: "2024-01-15", "2024-01", "2024"
|
||||
if (date.length >= 10) {
|
||||
// Full date: 2024-01-15
|
||||
final parts = date.substring(0, 10).split('-');
|
||||
if (parts.length == 3) {
|
||||
return '${parts[2]}/${parts[1]}/${parts[0]}'; // DD/MM/YYYY
|
||||
return '${parts[2]}/${parts[1]}/${parts[0]}';
|
||||
}
|
||||
} else if (date.length >= 7) {
|
||||
// Month: 2024-01
|
||||
final parts = date.split('-');
|
||||
if (parts.length >= 2) {
|
||||
return '${parts[1]}/${parts[0]}'; // MM/YYYY
|
||||
return '${parts[1]}/${parts[0]}';
|
||||
}
|
||||
}
|
||||
return date; // Year only or unknown format
|
||||
return date;
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
Future<void> _fetchTracks() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
Map<String, dynamic> metadata;
|
||||
|
||||
|
||||
if (widget.albumId.startsWith('deezer:')) {
|
||||
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
|
||||
metadata = await PlatformBridge.getDeezerMetadata(
|
||||
'album',
|
||||
deezerAlbumId,
|
||||
);
|
||||
} else {
|
||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
}
|
||||
|
||||
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
// Extract artist ID from album_info if available
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||
final artistId = albumInfo?['artist_id'] as String?;
|
||||
|
||||
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
@@ -214,15 +209,19 @@ Future<void> _fetchTracks() async {
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme),
|
||||
if (_isLoading)
|
||||
const SliverToBoxAdapter(child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)),
|
||||
if (_error != null)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _buildErrorWidget(_error!, colorScheme),
|
||||
)),
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
if (_error != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _buildErrorWidget(_error!, colorScheme),
|
||||
),
|
||||
),
|
||||
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
||||
_buildTrackListHeader(context, colorScheme),
|
||||
_buildTrackList(context, colorScheme, tracks),
|
||||
@@ -234,12 +233,17 @@ Future<void> _fetchTracks() async {
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5; // 50% of screen width
|
||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||
|
||||
final mediaSize = MediaQuery.of(context).size;
|
||||
final screenWidth = mediaSize.width;
|
||||
final shortestSide = mediaSize.shortestSide;
|
||||
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
|
||||
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
|
||||
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 320,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
@@ -260,27 +264,60 @@ Future<void> _fetchTracks() async {
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
||||
final collapseRatio =
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
|
||||
final dpr = MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
).clamp(1.0, 3.0).toDouble();
|
||||
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
|
||||
.round()
|
||||
.clamp(720, 1440)
|
||||
.toInt();
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background with dominant color
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
bgColor,
|
||||
bgColor.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
// Blurred cover background
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: backgroundMemCacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
)
|
||||
else
|
||||
Container(color: colorScheme.surface),
|
||||
ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Container(
|
||||
color: colorScheme.surface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: bottomGradientHeight,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface.withValues(alpha: 0.0),
|
||||
colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -289,7 +326,7 @@ Future<void> _fetchTracks() async {
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
padding: EdgeInsets.only(top: coverTopPadding),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
@@ -306,15 +343,19 @@ Future<void> _fetchTracks() async {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (coverSize * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
child: Icon(
|
||||
Icons.album,
|
||||
size: fallbackIconSize,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -323,7 +364,10 @@ Future<void> _fetchTracks() async {
|
||||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
stretchModes: const [
|
||||
StretchMode.zoomBackground,
|
||||
StretchMode.blurBackground,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -331,7 +375,7 @@ Future<void> _fetchTracks() async {
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
@@ -341,18 +385,20 @@ Future<void> _fetchTracks() async {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
final tracks = _tracks ?? [];
|
||||
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
||||
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -360,7 +406,10 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
children: [
|
||||
Text(
|
||||
widget.albumName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (artistName != null && artistName.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
@@ -381,27 +430,61 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 14,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
Text(
|
||||
context.l10n.tracksCount(tracks.length),
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (releaseDate != null && releaseDate.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer),
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 14,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
Text(
|
||||
_formatReleaseDate(releaseDate),
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -415,7 +498,9 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
label: Text(context.l10n.downloadAllCount(tracks.length)),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -435,28 +520,35 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||
Text(
|
||||
context.l10n.tracksHeader,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<Track> tracks) {
|
||||
Widget _buildTrackList(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
List<Track> tracks,
|
||||
) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _AlbumTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: tracks.length,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _AlbumTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
),
|
||||
);
|
||||
}, childCount: tracks.length),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -469,13 +561,23 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,30 +591,44 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: widget.albumName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToArtist(BuildContext context, String artistName) {
|
||||
// Use stored artist ID if available, otherwise use a placeholder
|
||||
final artistId = _artistId ??
|
||||
final artistId =
|
||||
_artistId ??
|
||||
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
|
||||
|
||||
// Don't navigate if artist ID is unknown
|
||||
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) {
|
||||
|
||||
if (artistId == 'unknown' ||
|
||||
artistId == 'deezer:unknown' ||
|
||||
artistId.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Artist information not available')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If from extension, use ExtensionArtistScreen
|
||||
|
||||
if (widget.extensionId != null) {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -527,7 +643,7 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -541,10 +657,11 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
|
||||
}
|
||||
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit = error.contains('429') ||
|
||||
error.toLowerCase().contains('rate limit') ||
|
||||
error.toLowerCase().contains('too many requests');
|
||||
|
||||
final isRateLimit =
|
||||
error.contains('429') ||
|
||||
error.toLowerCase().contains('rate limit') ||
|
||||
error.toLowerCase().contains('too many requests');
|
||||
|
||||
if (isRateLimit) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
@@ -583,7 +700,7 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||
@@ -594,7 +711,9 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
|
||||
Expanded(
|
||||
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -611,22 +730,44 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
|
||||
final queueItem = ref.watch(
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
|
||||
downloadQueueLookupProvider.select(
|
||||
(lookup) => lookup.byTrackId[track.id],
|
||||
),
|
||||
);
|
||||
|
||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
|
||||
final isInHistory = ref.watch(
|
||||
downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
}),
|
||||
);
|
||||
|
||||
final showLocalLibraryIndicator = ref.watch(
|
||||
settingsProvider.select(
|
||||
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
||||
),
|
||||
);
|
||||
final isInLocalLibrary = showLocalLibraryIndicator
|
||||
? ref.watch(
|
||||
localLibraryProvider.select(
|
||||
(state) => state.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
),
|
||||
),
|
||||
)
|
||||
: false;
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||
final progress = queueItem?.progress ?? 0.0;
|
||||
|
||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||
|
||||
final showAsDownloaded =
|
||||
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
@@ -634,8 +775,10 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
leading: SizedBox(
|
||||
width: 32,
|
||||
child: Center(
|
||||
@@ -648,51 +791,167 @@ child: ListTile(
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||
title: Text(
|
||||
track.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
if (isInLocalLibrary) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
context.l10n.libraryInLibrary,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: _buildDownloadButton(
|
||||
context,
|
||||
ref,
|
||||
colorScheme,
|
||||
isQueued: isQueued,
|
||||
isDownloading: isDownloading,
|
||||
isFinalizing: isFinalizing,
|
||||
showAsDownloaded: showAsDownloaded,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
progress: progress,
|
||||
),
|
||||
onTap: () => _handleTap(
|
||||
context,
|
||||
ref,
|
||||
isQueued: isQueued,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
||||
void _handleTap(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required bool isQueued,
|
||||
required bool isInHistory,
|
||||
required bool isInLocalLibrary,
|
||||
}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
|
||||
if (isInLocalLibrary) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
final historyItem = ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAlreadyDownloaded(track.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||
ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.removeBySpotifyId(track.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onDownload();
|
||||
}
|
||||
|
||||
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
|
||||
Widget _buildDownloadButton(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme, {
|
||||
required bool isQueued,
|
||||
required bool isDownloading,
|
||||
required bool isFinalizing,
|
||||
required bool showAsDownloaded,
|
||||
required bool isInHistory,
|
||||
required bool isInLocalLibrary,
|
||||
required double progress,
|
||||
}) {
|
||||
const double size = 44.0;
|
||||
const double iconSize = 20.0;
|
||||
|
||||
|
||||
if (showAsDownloaded) {
|
||||
return GestureDetector(
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
||||
onTap: () => _handleTap(
|
||||
context,
|
||||
ref,
|
||||
isQueued: isQueued,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (isFinalizing) {
|
||||
return SizedBox(
|
||||
@@ -701,7 +960,11 @@ child: ListTile(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: colorScheme.tertiary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||
],
|
||||
),
|
||||
@@ -713,17 +976,54 @@ child: ListTile(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||
CircularProgressIndicator(
|
||||
value: progress > 0 ? progress : null,
|
||||
strokeWidth: 3,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
if (progress > 0)
|
||||
Text(
|
||||
'${(progress * 100).toInt()}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isQueued) {
|
||||
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.hourglass_empty,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: iconSize,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return GestureDetector(
|
||||
onTap: onDownload,
|
||||
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.download,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user