Compare commits

...

235 Commits

Author SHA1 Message Date
zarzet 107d9ca007 feat: export failed downloads to TXT file
- Add Export button in queue when there are failed downloads
- Add auto-export setting in Download Settings
- Export includes track name, artist, Spotify/Deezer URL, and error message
- Clear Failed action in snackbar after export
2026-02-02 07:02:04 +07:00
zarzet 4633c7253a fix(ios): block iCloud Drive folder selection
- Detect iCloud path and show error when user tries to select it
- Fallback to app Documents folder if iCloud path detected at runtime
- Add localization string for iCloud not supported error
2026-02-01 21:14:45 +07:00
zarzet 8ace180fa8 fix: service selection priority and Amazon fallback-only
- Fix service selection ignored: user's preferred service now takes priority
- Add preferredService parameter to downloadWithExtensions
- Gray out Amazon in service picker (fallback only)
- Clean up unused code in Go backend
2026-02-01 21:04:35 +07:00
zarzet b9c3f2f0dd fix: remove duplicate plugin registration warning
Remove manual GeneratedPluginRegistrant.registerWith() call since
super.configureFlutterEngine() already handles this automatically.
2026-02-01 20:18:51 +07:00
zarzet 81b0eede8c v3.3.5: Same as 3.3.1 but fixes crash issues caused by FFmpeg
Changes:
- Fix FFmpeg crash issues during M4A to MP3/Opus conversion
- Add format picker (MP3/Opus) when selecting Tidal Lossy 320kbps
- Fix Deezer album blank screen when opened from home
- LRC file generation now follows lyrics mode setting
- Version bump to 3.3.5 (build 70)
2026-02-01 20:12:00 +07:00
zarzet eb0cdbeba8 feat(tidal): convert M4A to MP3/Opus for HIGH quality, remove LOSSY option
- Add tidalHighFormat setting (mp3_320 or opus_128) for Tidal HIGH quality
- Add convertM4aToLossy() in FFmpegService for M4A to MP3/Opus conversion
- Remove inefficient LOSSY option (FLAC download then convert)
- Update download_queue_provider to handle HIGH quality conversion
- Clean up LOSSY references from download_service_picker and log messages
- Update Go backend: amazon.go, tidal.go, metadata.go improvements
- UI: minor updates to album, playlist, and home screens
2026-02-01 19:07:02 +07:00
zarzet ee212a0e48 fix(tidal): fix DASH download path for HIGH quality AAC
- Fix m4aPath calculation in downloadFromManifest for HIGH quality
- When outputPath is already .m4a, use it directly instead of appending .m4a
- Reset httputil.go to fix build errors from merge conflict
2026-02-01 17:44:19 +07:00
zarzet 2073516666 feat(tidal): add native AAC 320kbps quality option
- Add HIGH quality option (AAC 320kbps) for Tidal downloads
- Download directly as M4A without FLAC conversion
- Embed metadata to M4A using EmbedM4AMetadata()
- Skip M4A to FLAC conversion in download provider for HIGH quality
- Add AAC 320kbps option in settings page (Tidal only)
- Add HIGH quality option in download service picker
2026-02-01 17:26:25 +07:00
zarzet 9d479b61d6 Merge main into dev 2026-02-01 17:25:01 +07:00
zarzet 203e6bc4eb docs: fix #108 reference in changelog - MP3 403 error fix 2026-02-01 16:23:16 +07:00
zarzet 5f1ffbee4e fix(android): manually register Flutter plugins for proper initialization 2026-02-01 16:15:19 +07:00
zarzet b29dc63337 fix: release build crash on Android 15+ (API 36)
- Add ProGuard rules for Flutter plugins (path_provider, local_notifications, receive_sharing_intent, etc.)
- Upgrade Go to 1.25.6 for 16KB page alignment support
- Expand ProGuard rules for Go backend and Kotlin coroutines
- Fix R8 stripping plugin implementations in release builds
2026-01-31 17:17:08 +07:00
zarzet 29699117dc fix(ci): add -tags ios to gomobile bind for iOS build 2026-01-31 15:34:34 +07:00
zarzet 3c75f9ecc6 fix(ios): separate uTLS code with build tags for iOS compatibility
- Create httputil_utls.go with uTLS/Cloudflare bypass for Android (build tag: !ios)
- Create httputil_ios.go with fallback implementation for iOS (build tag: ios)
- Remove uTLS-dependent code from httputil.go (shared code)
- Fixes iOS build failure due to undefined DNS resolver symbols (_res_9_*)
2026-01-31 15:31:21 +07:00
zarzet 79340703c1 fix(ios): add filter parameter to SearchDeezerAll call 2026-01-31 15:16:51 +07:00
zarzet df23e3f96c docs: remove outdated suspension notice from README 2026-01-31 15:12:14 +07:00
zarzet d9f788ddeb chore: fix linter warnings and remove unused functions 2026-01-31 15:12:13 +07:00
zarzet 62afbdcaaa feat: show quality badge for lossy formats (MP3/Opus) in history 2026-01-31 15:12:13 +07:00
zarzet 6c578cfd78 fix: show correct audio quality for lossy files in metadata screen 2026-01-31 15:12:13 +07:00
zarzet a17abec799 fix: preserve golang.org/x/mobile/bind dependency for gomobile 2026-01-31 15:12:13 +07:00
zarzet 2a71b70a34 perf: optimize cache cleanup and reduce unnecessary widget rebuilds 2026-01-31 15:12:13 +07:00
zarzet 03f77daf19 docs: add VPN compatibility to changelog 2026-01-31 15:12:13 +07:00
zarzet 270b0c1af6 feat(http): add uTLS Chrome fingerprint for Cloudflare bypass
- Added uTLS library to mimic Chrome's TLS fingerprint
- Uses HTTP/2 for optimal performance with uTLS
- Auto-detects Cloudflare challenge and retries with Chrome fingerprint
- Helps VPN users bypass Cloudflare TLS fingerprint detection
2026-01-31 15:12:12 +07:00
zarzet 317bb523a4 docs: add optional all files access to changelog 2026-01-31 15:12:12 +07:00
zarzet 2c8ad87b7e feat(android13): make All Files Access optional
- Android 13+ now only requires READ_MEDIA_AUDIO by default
- MANAGE_EXTERNAL_STORAGE is optional and can be enabled in Settings
- Added 'All Files Access' toggle in Download Settings (Android 13+ only)
- Users who encounter write errors can enable full storage access
- Respects privacy-conscious users who prefer limited permissions
2026-01-31 15:12:12 +07:00
zarzet 5e06729029 docs: shorten changelog entries for v3.3.0 2026-01-31 15:12:11 +07:00
zarzet 21bcfe1157 docs: update changelog with Opus cover art fix details 2026-01-31 15:12:11 +07:00
zarzet 3aeaaaf4f2 fix(opus): implement METADATA_BLOCK_PICTURE for cover art embedding
- OGG/Opus container doesn't support video stream for cover art
- Implemented FLAC picture block format with base64 encoding
- Cover art now embedded via METADATA_BLOCK_PICTURE Vorbis comment tag
- Follows OGG/Vorbis specification for embedded pictures
2026-01-31 15:12:11 +07:00
zarzet 3a9d1395db feat(ui): add Clear All button to download queue header (#96) 2026-01-31 15:12:11 +07:00
zarzet 90c46d99d4 chore: bump version to 3.3.0 2026-01-31 15:12:11 +07:00
zarzet 96f44fefd4 fix(ui): remove duplicate Embed Lyrics setting from Options page (#110) 2026-01-31 15:12:11 +07:00
zarzet 38a0a76b69 chore: update special thanks - add sjdonado (IDHS), remove DoubleDouble 2026-01-31 15:12:11 +07:00
zarzet 7fc73b6038 feat(backend): add IDHS as fallback link resolver when SongLink fails 2026-01-31 15:12:10 +07:00
zarzet 6b61dbc2da docs: update CHANGELOG with recent changes 2026-01-31 15:12:10 +07:00
zarzet fd3158fd15 feat: add search filters for Deezer default search
- Add filter parameter to Deezer SearchAll (track/artist/album/playlist)
- When filter is specified, increase limit for that type only
- Add default Deezer filters when not using extension search
- Reduce artist limit from 5 to 2 in home search results
- Filter bar now shows for both extension and default Deezer search
- Fix filter not being passed correctly during search (preserve filter state)
2026-01-31 15:12:10 +07:00
zarzet ff7135bf2c feat: add playlist search to Deezer default search
- Add SearchPlaylist class and parsing in track_provider.dart
- Add playlist search to Deezer SearchAll API (5 results)
- Add SearchPlaylistResult struct in Go backend
- Add _SearchPlaylistItemWidget for displaying playlists
- Add _navigateToSearchPlaylist method
- Update PlaylistScreen to support fetching tracks by playlistId
- Display playlists in search results alongside artists and albums
2026-01-31 15:12:10 +07:00
zarzet 74bac570c7 feat: unify search results display and add album search to Deezer
- Add SearchAlbumResult struct to Go backend
- Add album search to Deezer SearchAll() function (returns albums alongside tracks/artists)
- Change artist display from horizontal scroll to vertical list style (consistent with extension search)
- Add SearchAlbum class and searchAlbums field to TrackState
- Add _SearchArtistItemWidget and _SearchAlbumItemWidget for vertical list display
- Add _navigateToSearchAlbum method for navigating to album details
- Remove old horizontal artist scroll (_buildArtistSearchResults, _buildArtistCard)

Now default search (Deezer/Spotify) shows Artists, Albums, and Songs in the same vertical list style as extension search results.
2026-01-31 15:12:10 +07:00
zarzet 5f999035c3 fix(deezer): add pagination for albums and playlists with >25 tracks
- Deezer API default limit is 25 tracks per request

- Now fetches all tracks using pagination for albums >25 tracks

- Also fixes playlists with >25 tracks

- Fixes issue where Greatest Hits albums only showed 25 tracks
2026-01-31 15:12:09 +07:00
zarzet fa7b5a3559 docs: add Turkish translators credit
- Add Kaan (glai) and BedirhanGltkn as Turkish translators
2026-01-31 15:12:09 +07:00
zarzet 187821b2ae docs: add Japanese translator credit and fix Opus bitrate
- Add Re*Index.(ot_inc) as Japanese translator in About page

- Fix CHANGELOG: Opus is 128kbps not 256kbps
2026-01-31 15:12:09 +07:00
zarzet 1435ba9658 fix: add cover art embedding for Opus files
- Add embedMetadataToOpus() in FFmpegService

- Add _embedMetadataToOpus() in download queue provider

- Now both MP3 and Opus get cover art embedded after conversion

- Previously Opus files had no cover art (only audio was copied)
2026-01-31 15:12:09 +07:00
zarzet 62e2e1703c refactor: remove emojis from Go backend code
- Remove emoji detection (checkmark/cross) from GoLog() in logbuffer.go

- Remove emojis from log messages in tidal.go, qobuz.go, amazon.go

- Add code-style.md steering rule for no emojis in code

- Update logging.md to remove emoji examples
2026-01-31 15:12:09 +07:00
zarzet 21a732379b feat(backend): update Amazon and Qobuz download APIs
Amazon:
- Replace DoubleDouble service with AfkarXYZ API
- Simpler implementation without polling mechanism
- Remove rate limiting (not needed for AfkarXYZ)

Qobuz:
- Add qobuz.squid.wtf as additional API endpoint
- Add Jumo API as fallback when standard APIs fail
- Add XOR decoding for Jumo response
- Add quality fallback (27 -> 7 -> 6) for Jumo
2026-01-31 15:12:09 +07:00
zarzet 8ac035d146 chore: remove obsolete iOS-specific files
- Delete pubspec_ios.yaml (now identical to main pubspec.yaml)
- Delete build_assets/ffmpeg_service_ios.dart (main service works for both platforms)
- Remove iOS pubspec/FFmpeg swap steps from release.yml
- Both Android and iOS now use ffmpeg_kit_flutter_new_audio plugin
2026-01-31 15:11:19 +07:00
zarzet d7e7fb065e docs: update CHANGELOG for v3.2.2 2026-01-31 15:11:19 +07:00
zarzet 11d3b8ab3b chore(l10n): complete Turkish and Portuguese Portugal translations to 70%+ threshold
- Turkish (tr): 84% (533/638 keys)
- Portuguese Portugal (pt_PT): 89% (567/638 keys)

Both languages now included in supported locales.
2026-01-31 15:11:19 +07:00
zarzet 566e5996bc chore: fix locale file naming (dash to underscore) and regenerate l10n 2026-01-31 15:11:18 +07:00
Zarz Eleutherius 51618c7dbd New translations app_en.arb (German) 2026-01-31 15:11:18 +07:00
Zarz Eleutherius bdff3a6135 New translations app_en.arb (Russian) 2026-01-31 15:11:18 +07:00
Zarz Eleutherius ef7cd4ff5d New translations app_en.arb (Spanish) 2026-01-31 15:11:18 +07:00
Zarz Eleutherius 431e437dee New translations app_en.arb (Indonesian) 2026-01-31 15:11:18 +07:00
Zarz Eleutherius cebd43e75a New translations app_en.arb (Turkish) 2026-01-31 15:11:18 +07:00
Zarz Eleutherius 17bfbf95f2 New translations app_en.arb (Hindi) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius dad525be40 New translations app_en.arb (Indonesian) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius 7dd0dbd594 New translations app_en.arb (Chinese Traditional) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius a0bf423a50 New translations app_en.arb (Chinese Simplified) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius 288b060983 New translations app_en.arb (Russian) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius 5ba60d4fd0 New translations app_en.arb (Portuguese) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius 07dae97fe6 New translations app_en.arb (Dutch) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius b210f67728 New translations app_en.arb (Korean) 2026-01-31 15:11:17 +07:00
Zarz Eleutherius 728d1d58c2 New translations app_en.arb (Japanese) 2026-01-31 15:11:16 +07:00
Zarz Eleutherius 6b9650d451 New translations app_en.arb (German) 2026-01-31 15:11:16 +07:00
Zarz Eleutherius 72ae9072bf New translations app_en.arb (Spanish) 2026-01-31 15:11:16 +07:00
Zarz Eleutherius e82263dc14 New translations app_en.arb (French) 2026-01-31 15:11:16 +07:00
Zarz Eleutherius f03b218775 New translations app_en.arb (Turkish) 2026-01-31 15:11:16 +07:00
Zarz Eleutherius c840b59ae1 New translations app_en.arb (Hindi) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius 1213fc449a New translations app_en.arb (Indonesian) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius ca21bb0f0c New translations app_en.arb (Chinese Traditional) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius 00555b2df6 New translations app_en.arb (Chinese Simplified) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius efca120470 New translations app_en.arb (Russian) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius a178c3943a New translations app_en.arb (Portuguese) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius 01ed1f20ad New translations app_en.arb (Dutch) 2026-01-31 15:11:15 +07:00
Zarz Eleutherius e2bd67083e New translations app_en.arb (Korean) 2026-01-31 15:11:14 +07:00
Zarz Eleutherius 31fb0a87c9 New translations app_en.arb (Japanese) 2026-01-31 15:11:14 +07:00
Zarz Eleutherius ac4d9fc602 New translations app_en.arb (German) 2026-01-31 15:11:14 +07:00
Zarz Eleutherius 8b1b581dbe New translations app_en.arb (Spanish) 2026-01-31 15:11:14 +07:00
Zarz Eleutherius ebdaa24cfc New translations app_en.arb (French) 2026-01-31 15:11:14 +07:00
Zarz Eleutherius 5633e3adf8 New translations app_en.arb (Turkish) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius fcae5e066d New translations app_en.arb (Hindi) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius c312aea75f New translations app_en.arb (Indonesian) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius 1e6e19ecd2 New translations app_en.arb (Chinese Traditional) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius 0866b04766 New translations app_en.arb (Chinese Simplified) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius 78cef8d58e New translations app_en.arb (Russian) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius ce84aee8da New translations app_en.arb (Portuguese) 2026-01-31 15:11:13 +07:00
Zarz Eleutherius 1ba1665215 New translations app_en.arb (Dutch) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius 60fb18c8e2 New translations app_en.arb (Korean) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius c042b490b8 New translations app_en.arb (Japanese) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius f544b46d97 New translations app_en.arb (German) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius 70759724fe New translations app_en.arb (Spanish) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius fbfe252df6 New translations app_en.arb (French) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius 2c3def8c7b New translations app_en.arb (Hindi) 2026-01-31 15:11:12 +07:00
Zarz Eleutherius 47e67e8299 New translations app_en.arb (Indonesian) 2026-01-31 15:10:33 +07:00
Zarz Eleutherius ec15516230 New translations app_en.arb (Chinese Traditional) 2026-01-31 15:10:22 +07:00
Zarz Eleutherius 462013bc2a New translations app_en.arb (Chinese Simplified) 2026-01-31 15:10:22 +07:00
Zarz Eleutherius 6b5e53864d New translations app_en.arb (Russian) 2026-01-31 15:10:21 +07:00
Zarz Eleutherius a8a47589c8 New translations app_en.arb (Portuguese) 2026-01-31 15:10:21 +07:00
Zarz Eleutherius b9d567d421 New translations app_en.arb (Dutch) 2026-01-31 15:10:21 +07:00
Zarz Eleutherius 81c77af558 New translations app_en.arb (Korean) 2026-01-31 15:10:21 +07:00
Zarz Eleutherius 1121680da6 New translations app_en.arb (Japanese) 2026-01-31 15:10:20 +07:00
Zarz Eleutherius d31f2e8894 New translations app_en.arb (German) 2026-01-31 15:10:20 +07:00
Zarz Eleutherius 5895a59cb2 New translations app_en.arb (Spanish) 2026-01-31 15:10:20 +07:00
Zarz Eleutherius 3e5e8d7a42 New translations app_en.arb (French) 2026-01-31 15:10:20 +07:00
zarzet 518a7fd2cf feat: replace custom FFmpeg AAR with ffmpeg_kit_flutter plugin, add Lossy format support (MP3/Opus)
- Replace custom ffmpeg-kit-with-lame.aar with ffmpeg_kit_flutter_new_audio plugin
- Rename MP3 option to Lossy with format selection (MP3 320kbps or Opus 128kbps)
- Add convertFlacToOpus() and convertFlacToLossy() functions in FFmpegService
- Update settings model: enableMp3Option -> enableLossyOption, add lossyFormat field
- Update download_queue_provider to use LOSSY quality with format from settings
- Remove FFMPEG_CHANNEL MethodChannel from MainActivity.kt
- Delete custom FFmpeg AAR files from android/app/libs/
- Add new localization strings for lossy format options
2026-01-31 15:10:20 +07:00
zarzet 6c832d1754 fix: MP3 download returns 403 - download FLAC first then convert
When user selects MP3 quality, the app was sending 'MP3' directly to
Tidal/Qobuz APIs which don't support MP3 as a quality parameter,
resulting in 403 Forbidden errors.

Fix: Convert quality 'MP3' to 'LOSSLESS' before sending to backend,
then convert the downloaded FLAC to MP3 using FFmpeg (existing logic).
2026-01-31 15:10:19 +07:00
zarzet d898b5f23e chore: revert version to 3.2.2+66 2026-01-31 15:10:19 +07:00
zarzet c38a1428f1 chore: ignore Claude local settings file 2026-01-31 15:10:19 +07:00
zarzet 759eeccc1f fix: disable Impeller on legacy/problematic GPUs for stability
Add dynamic GPU detection to use Skia renderer instead of Impeller on:
- Known problematic device models (Nexus 5, Samsung Tab A7 Lite, etc.)
- Problematic chipsets (MSM8974, MT6762, etc.)
- Legacy GPUs (Adreno 300/400, Mali-400/T6, PowerVR SGX, etc.)
- Android versions < 8.0 (API 26)

This fixes SIGSEGV crashes in libsc-a3xx.so GPU shader compiler
on older Qualcomm Adreno devices when Impeller attempts to
compile Vulkan/OpenGL shaders.

Uses FlutterShellArgs --enable-impeller=false which is the only
reliable method since AndroidManifest meta-data is broken in
Flutter 3.27+ (flutter/flutter#160595)
2026-01-31 15:10:18 +07:00
zarzet d0bc3b203c feat: add search filter bar for extension custom search
- Add SearchFilter struct in Go backend and Dart
- Add filters array to SearchBehaviorConfig manifest
- Add selectedSearchFilter state to TrackProvider
- Add filter bar UI with FilterChips below search bar
- Filter bar only shows when search results exist or loading
- Preserve selectedSearchFilter during customSearch loading
- Pass filter option to extension customSearch
2026-01-31 15:10:18 +07:00
zarzet 831b68b6cc fix: update Telegram community link in About page 2026-01-31 15:10:18 +07:00
zarzet a06111f445 chore: bump version to 26.2.1+65 (new year.month.day format) 2026-01-31 15:10:18 +07:00
zarzet 31fdd30c13 fix: use --data-urlencode for Telegram message to handle special chars (+, &) 2026-01-31 15:10:17 +07:00
zarzet e207ef89d5 docs: remove outdated suspension notice from README 2026-01-31 14:57:36 +07:00
zarzet 1261da2e5b chore: fix linter warnings and remove unused functions 2026-01-31 14:55:46 +07:00
zarzet 0c917bc41e feat: show quality badge for lossy formats (MP3/Opus) in history 2026-01-31 14:52:20 +07:00
zarzet f525d6c7e6 fix: show correct audio quality for lossy files in metadata screen 2026-01-31 14:50:19 +07:00
zarzet ed7c67a622 fix: preserve golang.org/x/mobile/bind dependency for gomobile 2026-01-31 14:37:43 +07:00
zarzet 99281df5fb perf: optimize cache cleanup and reduce unnecessary widget rebuilds 2026-01-31 14:29:14 +07:00
zarzet 24c2fd6a15 docs: add VPN compatibility to changelog 2026-01-31 14:18:09 +07:00
zarzet ec3fe34dc0 feat(http): add uTLS Chrome fingerprint for Cloudflare bypass
- Added uTLS library to mimic Chrome's TLS fingerprint
- Uses HTTP/2 for optimal performance with uTLS
- Auto-detects Cloudflare challenge and retries with Chrome fingerprint
- Helps VPN users bypass Cloudflare TLS fingerprint detection
2026-01-31 14:17:23 +07:00
zarzet 56f36da5f9 docs: add optional all files access to changelog 2026-01-31 14:10:02 +07:00
zarzet 9bbd774175 feat(android13): make All Files Access optional
- Android 13+ now only requires READ_MEDIA_AUDIO by default
- MANAGE_EXTERNAL_STORAGE is optional and can be enabled in Settings
- Added 'All Files Access' toggle in Download Settings (Android 13+ only)
- Users who encounter write errors can enable full storage access
- Respects privacy-conscious users who prefer limited permissions
2026-01-31 14:08:59 +07:00
zarzet 020ac32ee6 docs: shorten changelog entries for v3.3.0 2026-01-31 13:55:11 +07:00
zarzet 67a72210ac docs: update changelog with Opus cover art fix details 2026-01-31 13:52:14 +07:00
zarzet 020f41fd1e fix(opus): implement METADATA_BLOCK_PICTURE for cover art embedding
- OGG/Opus container doesn't support video stream for cover art
- Implemented FLAC picture block format with base64 encoding
- Cover art now embedded via METADATA_BLOCK_PICTURE Vorbis comment tag
- Follows OGG/Vorbis specification for embedded pictures
2026-01-31 13:41:28 +07:00
zarzet 820eb8cc32 feat(ui): add Clear All button to download queue header (#96) 2026-01-31 13:21:12 +07:00
zarzet 47fa5c2009 chore: bump version to 3.3.0 2026-01-31 13:17:08 +07:00
zarzet 9b0c929423 fix(ui): remove duplicate Embed Lyrics setting from Options page (#110) 2026-01-31 13:06:07 +07:00
zarzet 93105a45fe chore: update special thanks - add sjdonado (IDHS), remove DoubleDouble 2026-01-31 13:03:32 +07:00
zarzet d8b2f4d367 feat(backend): add IDHS as fallback link resolver when SongLink fails 2026-01-31 12:54:11 +07:00
zarzet f1478bb2ca docs: update CHANGELOG with recent changes 2026-01-31 12:35:51 +07:00
zarzet 8b3c377688 feat: add search filters for Deezer default search
- Add filter parameter to Deezer SearchAll (track/artist/album/playlist)
- When filter is specified, increase limit for that type only
- Add default Deezer filters when not using extension search
- Reduce artist limit from 5 to 2 in home search results
- Filter bar now shows for both extension and default Deezer search
- Fix filter not being passed correctly during search (preserve filter state)
2026-01-31 12:34:58 +07:00
zarzet 8c98b02dca feat: add playlist search to Deezer default search
- Add SearchPlaylist class and parsing in track_provider.dart
- Add playlist search to Deezer SearchAll API (5 results)
- Add SearchPlaylistResult struct in Go backend
- Add _SearchPlaylistItemWidget for displaying playlists
- Add _navigateToSearchPlaylist method
- Update PlaylistScreen to support fetching tracks by playlistId
- Display playlists in search results alongside artists and albums
2026-01-31 12:12:14 +07:00
zarzet 3743e35e8a feat: unify search results display and add album search to Deezer
- Add SearchAlbumResult struct to Go backend
- Add album search to Deezer SearchAll() function (returns albums alongside tracks/artists)
- Change artist display from horizontal scroll to vertical list style (consistent with extension search)
- Add SearchAlbum class and searchAlbums field to TrackState
- Add _SearchArtistItemWidget and _SearchAlbumItemWidget for vertical list display
- Add _navigateToSearchAlbum method for navigating to album details
- Remove old horizontal artist scroll (_buildArtistSearchResults, _buildArtistCard)

Now default search (Deezer/Spotify) shows Artists, Albums, and Songs in the same vertical list style as extension search results.
2026-01-31 12:00:29 +07:00
zarzet 05a02de4a9 fix(deezer): add pagination for albums and playlists with >25 tracks
- Deezer API default limit is 25 tracks per request

- Now fetches all tracks using pagination for albums >25 tracks

- Also fixes playlists with >25 tracks

- Fixes issue where Greatest Hits albums only showed 25 tracks
2026-01-31 11:47:00 +07:00
zarzet c28378cbb5 docs: add Turkish translators credit
- Add Kaan (glai) and BedirhanGltkn as Turkish translators
2026-01-31 11:41:34 +07:00
zarzet b2bef63b6b docs: add Japanese translator credit and fix Opus bitrate
- Add Re*Index.(ot_inc) as Japanese translator in About page

- Fix CHANGELOG: Opus is 128kbps not 256kbps
2026-01-31 11:40:53 +07:00
zarzet 6513e14b21 fix: add cover art embedding for Opus files
- Add embedMetadataToOpus() in FFmpegService

- Add _embedMetadataToOpus() in download queue provider

- Now both MP3 and Opus get cover art embedded after conversion

- Previously Opus files had no cover art (only audio was copied)
2026-01-31 11:37:12 +07:00
zarzet fd53755ad6 refactor: remove emojis from Go backend code
- Remove emoji detection (checkmark/cross) from GoLog() in logbuffer.go

- Remove emojis from log messages in tidal.go, qobuz.go, amazon.go

- Add code-style.md steering rule for no emojis in code

- Update logging.md to remove emoji examples
2026-01-31 11:28:12 +07:00
zarzet 1dbacb3027 feat(backend): update Amazon and Qobuz download APIs
Amazon:
- Replace DoubleDouble service with AfkarXYZ API
- Simpler implementation without polling mechanism
- Remove rate limiting (not needed for AfkarXYZ)

Qobuz:
- Add qobuz.squid.wtf as additional API endpoint
- Add Jumo API as fallback when standard APIs fail
- Add XOR decoding for Jumo response
- Add quality fallback (27 -> 7 -> 6) for Jumo
2026-01-31 11:24:35 +07:00
zarzet 910d9a7662 chore: remove obsolete iOS-specific files
- Delete pubspec_ios.yaml (now identical to main pubspec.yaml)
- Delete build_assets/ffmpeg_service_ios.dart (main service works for both platforms)
- Remove iOS pubspec/FFmpeg swap steps from release.yml
- Both Android and iOS now use ffmpeg_kit_flutter_new_audio plugin
2026-01-31 11:18:50 +07:00
zarzet 09bd8c6b21 docs: update CHANGELOG for v3.2.2 2026-01-31 11:16:06 +07:00
zarzet 908d108858 chore(l10n): complete Turkish and Portuguese Portugal translations to 70%+ threshold
- Turkish (tr): 84% (533/638 keys)
- Portuguese Portugal (pt_PT): 89% (567/638 keys)

Both languages now included in supported locales.
2026-01-31 11:12:13 +07:00
zarzet 3135993cf4 chore: fix locale file naming (dash to underscore) and regenerate l10n 2026-01-31 10:56:26 +07:00
zarzet 7a315b5fd4 Merge PR #85: New Crowdin updates - localization updates for multiple languages 2026-01-31 10:54:18 +07:00
zarzet 4bd6dcc3d7 feat: replace custom FFmpeg AAR with ffmpeg_kit_flutter plugin, add Lossy format support (MP3/Opus)
- Replace custom ffmpeg-kit-with-lame.aar with ffmpeg_kit_flutter_new_audio plugin
- Rename MP3 option to Lossy with format selection (MP3 320kbps or Opus 128kbps)
- Add convertFlacToOpus() and convertFlacToLossy() functions in FFmpegService
- Update settings model: enableMp3Option -> enableLossyOption, add lossyFormat field
- Update download_queue_provider to use LOSSY quality with format from settings
- Remove FFMPEG_CHANNEL MethodChannel from MainActivity.kt
- Delete custom FFmpeg AAR files from android/app/libs/
- Add new localization strings for lossy format options
2026-01-31 08:03:38 +07:00
zarzet 3f7fa19cdf fix: MP3 download returns 403 - download FLAC first then convert
When user selects MP3 quality, the app was sending 'MP3' directly to
Tidal/Qobuz APIs which don't support MP3 as a quality parameter,
resulting in 403 Forbidden errors.

Fix: Convert quality 'MP3' to 'LOSSLESS' before sending to backend,
then convert the downloaded FLAC to MP3 using FFmpeg (existing logic).
2026-01-31 07:53:13 +07:00
Zarz Eleutherius 867ec4d125 Enhance README with support and disclaimer sections
Added a section for supporting the project and a disclaimer about usage.
2026-01-30 17:24:30 +07:00
Zarz Eleutherius 164467f3a2 Update GitHub badge link with refresh parameter 2026-01-28 18:54:57 +07:00
Zarz Eleutherius fc9a2ddc2a New translations app_en.arb (German) 2026-01-25 12:23:03 +07:00
Zarz Eleutherius 543cb45c11 Merge pull request #104 from Amonoman/main
Update about_page.dart
2026-01-25 03:20:53 +07:00
Zarz Eleutherius c49e5adc52 New translations app_en.arb (Russian) 2026-01-24 12:05:26 +07:00
Zarz Eleutherius 0fedd446ca New translations app_en.arb (Spanish) 2026-01-24 12:05:25 +07:00
zarzet 0c7b8a68d9 chore: revert version to 3.2.2+66 2026-01-24 09:06:36 +07:00
zarzet 6dd6accbcc chore: ignore Claude local settings file 2026-01-24 09:02:37 +07:00
zarzet ca67f7f79d fix: disable Impeller on legacy/problematic GPUs for stability
Add dynamic GPU detection to use Skia renderer instead of Impeller on:
- Known problematic device models (Nexus 5, Samsung Tab A7 Lite, etc.)
- Problematic chipsets (MSM8974, MT6762, etc.)
- Legacy GPUs (Adreno 300/400, Mali-400/T6, PowerVR SGX, etc.)
- Android versions < 8.0 (API 26)

This fixes SIGSEGV crashes in libsc-a3xx.so GPU shader compiler
on older Qualcomm Adreno devices when Impeller attempts to
compile Vulkan/OpenGL shaders.

Uses FlutterShellArgs --enable-impeller=false which is the only
reliable method since AndroidManifest meta-data is broken in
Flutter 3.27+ (flutter/flutter#160595)
2026-01-24 09:02:32 +07:00
zarzet 1aa12c5857 feat: add search filter bar for extension custom search
- Add SearchFilter struct in Go backend and Dart
- Add filters array to SearchBehaviorConfig manifest
- Add selectedSearchFilter state to TrackProvider
- Add filter bar UI with FilterChips below search bar
- Filter bar only shows when search results exist or loading
- Preserve selectedSearchFilter during customSearch loading
- Pass filter option to extension customSearch
2026-01-24 08:50:41 +07:00
Amonoman 80707fc438 Update about_page.dart
i changed it becouse "Max" is not my username
2026-01-23 20:34:43 +01:00
Zarz Eleutherius ff121dfeb8 New translations app_en.arb (Indonesian) 2026-01-23 11:53:28 +07:00
zarzet c3aa6a441b fix: update Telegram community link in About page 2026-01-22 07:58:55 +07:00
Zarz Eleutherius 496d32e35b New translations app_en.arb (Turkish) 2026-01-22 07:34:38 +07:00
Zarz Eleutherius 291fa58757 New translations app_en.arb (Hindi) 2026-01-22 07:34:37 +07:00
Zarz Eleutherius eddbc2f986 New translations app_en.arb (Indonesian) 2026-01-22 07:34:36 +07:00
Zarz Eleutherius 81b8281d2c New translations app_en.arb (Chinese Traditional) 2026-01-22 07:34:35 +07:00
Zarz Eleutherius 57f87d9a4c New translations app_en.arb (Chinese Simplified) 2026-01-22 07:34:33 +07:00
Zarz Eleutherius c9d0c57d86 New translations app_en.arb (Russian) 2026-01-22 07:34:32 +07:00
Zarz Eleutherius 54ab5a9243 New translations app_en.arb (Portuguese) 2026-01-22 07:34:31 +07:00
Zarz Eleutherius 17b6b27cd7 New translations app_en.arb (Dutch) 2026-01-22 07:34:30 +07:00
Zarz Eleutherius ed131ca1fd New translations app_en.arb (Korean) 2026-01-22 07:34:29 +07:00
Zarz Eleutherius 190d65cdee New translations app_en.arb (Japanese) 2026-01-22 07:34:28 +07:00
Zarz Eleutherius dbf2e337f0 New translations app_en.arb (German) 2026-01-22 07:34:27 +07:00
Zarz Eleutherius 12e76bed4f New translations app_en.arb (Spanish) 2026-01-22 07:34:26 +07:00
Zarz Eleutherius e00db80dae New translations app_en.arb (French) 2026-01-22 07:34:24 +07:00
Zarz Eleutherius 5de0aa8145 Update source file app_en.arb 2026-01-22 07:34:20 +07:00
zarzet 91ffb25027 chore: bump version to 26.2.1+65 (new year.month.day format) 2026-01-22 07:06:15 +07:00
zarzet 6bcbdfedf0 Merge branch 'main' into dev 2026-01-22 07:04:03 +07:00
zarzet 3f42128cb9 fix: update Telegram community link and VirusTotal hash for v3.2.1 2026-01-22 04:50:46 +07:00
zarzet ccb8f98df5 fix: use --data-urlencode for Telegram message to handle special chars (+, &) 2026-01-22 04:26:02 +07:00
zarzet 591a597333 Merge branch 'dev'
# Conflicts:
#	.github/workflows/release.yml
#	README.md
2026-01-22 04:01:24 +07:00
zarzet 6388f3a5b8 perf: optimize providers, caching, and reduce rebuilds
- Cache SharedPreferences.getInstance() in providers (settings, theme, recent_access)
- Pre-compute download counts in queue provider to avoid repeated filtering
- Add identical() caching for RecentAccessView in HomeTab
- Use selective watching for exploreProvider (sections, greeting, isLoading only)
- Move isYTMusicQuickPicks computation to ExploreSection.fromJson()
- Hoist static RegExp patterns to avoid repeated compilation
- Use batch operations for iOS path migration in history_database
- Replace containsKey+lookup with single lookup in palette_service
- Pre-compute lowercase strings outside filter loops in logger
- Fix _isLoaded race condition in DownloadHistoryNotifier
2026-01-22 03:56:47 +07:00
Zarz Eleutherius 22f52f4af2 New translations app_en.arb (Turkish) 2026-01-22 02:27:30 +07:00
Zarz Eleutherius ceaaff8c9b New translations app_en.arb (Hindi) 2026-01-22 02:27:29 +07:00
Zarz Eleutherius a318495046 New translations app_en.arb (Indonesian) 2026-01-22 02:27:27 +07:00
Zarz Eleutherius 8ffc6d3821 New translations app_en.arb (Chinese Traditional) 2026-01-22 02:27:26 +07:00
Zarz Eleutherius 2036e46da0 New translations app_en.arb (Chinese Simplified) 2026-01-22 02:27:25 +07:00
Zarz Eleutherius b82000e87c New translations app_en.arb (Russian) 2026-01-22 02:27:24 +07:00
Zarz Eleutherius 144906fd8f New translations app_en.arb (Portuguese) 2026-01-22 02:27:23 +07:00
Zarz Eleutherius 8a109e9013 New translations app_en.arb (Dutch) 2026-01-22 02:27:21 +07:00
Zarz Eleutherius ba05f6b470 New translations app_en.arb (Korean) 2026-01-22 02:27:20 +07:00
Zarz Eleutherius 2f80ae7e84 New translations app_en.arb (Japanese) 2026-01-22 02:27:19 +07:00
Zarz Eleutherius e248fef130 New translations app_en.arb (German) 2026-01-22 02:27:18 +07:00
Zarz Eleutherius 174724ddd3 New translations app_en.arb (Spanish) 2026-01-22 02:27:17 +07:00
Zarz Eleutherius 730945d892 New translations app_en.arb (French) 2026-01-22 02:27:15 +07:00
Zarz Eleutherius 4abdce8c58 Update source file app_en.arb 2026-01-22 02:27:13 +07:00
zarzet 55b75dc48d chore: bump version to 3.2.1+64 2026-01-22 02:17:47 +07:00
zarzet f6cea1a683 feat: v3.2.1 - lyrics improvements, pause/resume, folder options
- Add instrumental track detection (shows 'Instrumental track' instead of 'not available')
- Add embed lyrics button in Track Info (preserves synced timestamps)
- Add pause/resume button next to 'Downloading' header in History
- Add Artist/Album + Singles folder structure option
- Fix multi-artist lyrics search (try primary artist first)
- Fix lyrics display stripping metadata tags ([ti:], [ar:], [by:])
- Skip lyrics embedding for instrumental tracks during download
2026-01-22 02:15:43 +07:00
zarzet 8d205600b8 fix: iOS path migration, local greeting timezone, ICU plural warnings
- iOS: Auto-migrate file paths when container UUID changes after app update
- Greeting: Use device local time instead of extension response
- i18n: Fix 16 ICU plural syntax warnings in Spanish and Portuguese
2026-01-22 00:48:45 +07:00
zarzet aa35f60fad fix: fallback to index+1 for Deezer track position when API returns 0 2026-01-21 16:33:30 +07:00
zarzet b627ae1874 fix: handle CRLF in changelog extraction for Telegram 2026-01-21 16:23:19 +07:00
Zarz Eleutherius 0d98ada479 New translations app_en.arb (Turkish) 2026-01-21 02:22:48 +07:00
Zarz Eleutherius 5d4fc10ab7 New translations app_en.arb (Hindi) 2026-01-21 02:22:46 +07:00
Zarz Eleutherius e37dfeb080 New translations app_en.arb (Indonesian) 2026-01-21 02:22:45 +07:00
Zarz Eleutherius eddae2a9dd New translations app_en.arb (Chinese Traditional) 2026-01-21 02:22:44 +07:00
Zarz Eleutherius 6bd7eec615 New translations app_en.arb (Chinese Simplified) 2026-01-21 02:22:43 +07:00
Zarz Eleutherius b240e91290 New translations app_en.arb (Russian) 2026-01-21 02:22:42 +07:00
Zarz Eleutherius 4e0149df29 New translations app_en.arb (Portuguese) 2026-01-21 02:22:41 +07:00
Zarz Eleutherius 065872e686 New translations app_en.arb (Dutch) 2026-01-21 02:22:39 +07:00
Zarz Eleutherius 7ab0f5b7c8 New translations app_en.arb (Korean) 2026-01-21 02:22:38 +07:00
Zarz Eleutherius fd31682242 New translations app_en.arb (Japanese) 2026-01-21 02:22:37 +07:00
Zarz Eleutherius 56c8b62fcf New translations app_en.arb (German) 2026-01-21 02:22:36 +07:00
Zarz Eleutherius c3f879346a New translations app_en.arb (Spanish) 2026-01-21 02:22:35 +07:00
Zarz Eleutherius 6da65ed033 New translations app_en.arb (French) 2026-01-21 02:22:34 +07:00
Zarz Eleutherius 553c6b6c4a Update source file app_en.arb 2026-01-21 02:22:31 +07:00
zarzet ac5f74a48f feat: convert GitHub Markdown to Telegram format in release notification 2026-01-20 10:12:18 +07:00
zarzet 2d22d85c49 feat: improve Telegram notification - upload IPA, remove redundant links, increase changelog limit 2026-01-20 10:08:53 +07:00
zarzet 3edfe8e8bb docs: update README and release workflow 2026-01-20 09:56:38 +07:00
zarzet 6f9722e05b Merge dev: update screenshots, funding, and VirusTotal hash 2026-01-20 05:58:06 +07:00
zarzet 066d35967e Merge branch 'dev' 2026-01-20 04:55:27 +07:00
zarzet 2b932cff70 Merge branch 'dev' 2026-01-20 04:16:26 +07:00
Zarz Eleutherius a32487ad88 New translations app_en.arb (Hindi) 2026-01-20 02:16:58 +07:00
Zarz Eleutherius bd4946db37 New translations app_en.arb (Indonesian) 2026-01-20 02:16:57 +07:00
Zarz Eleutherius 69f143dd9d New translations app_en.arb (Chinese Traditional) 2026-01-20 02:16:56 +07:00
Zarz Eleutherius 15408bfa1c New translations app_en.arb (Chinese Simplified) 2026-01-20 02:16:55 +07:00
Zarz Eleutherius edc715021d New translations app_en.arb (Russian) 2026-01-20 02:16:54 +07:00
Zarz Eleutherius 392472b027 New translations app_en.arb (Portuguese) 2026-01-20 02:16:53 +07:00
Zarz Eleutherius 69741fa47c New translations app_en.arb (Dutch) 2026-01-20 02:16:52 +07:00
Zarz Eleutherius 484720bcda New translations app_en.arb (Korean) 2026-01-20 02:16:51 +07:00
Zarz Eleutherius f3cc51fb06 New translations app_en.arb (Japanese) 2026-01-20 02:16:50 +07:00
Zarz Eleutherius 452ea7084a New translations app_en.arb (German) 2026-01-20 02:16:49 +07:00
Zarz Eleutherius bba059fc44 New translations app_en.arb (Spanish) 2026-01-20 02:16:48 +07:00
Zarz Eleutherius 3f75cace2b New translations app_en.arb (French) 2026-01-20 02:16:47 +07:00
zarzet 556c0e1db2 Merge dev into main 2026-01-18 03:21:02 +07:00
zarzet 9897d3102e Merge branch 'dev' into main 2026-01-17 10:06:38 +07:00
zarzet 88dfb88bcc docs: add FAQ about mobile app size (FFmpeg bundled) 2026-01-17 07:11:07 +07:00
zarzet 75bfe9b3bf docs: update VirusTotal link for v3.1.0 2026-01-17 06:09:50 +07:00
zarzet f4fe74f972 docs: add Crowdin translation badge to README 2026-01-16 07:08:49 +07:00
105 changed files with 17903 additions and 7023 deletions
+18 -25
View File
@@ -71,7 +71,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.21" go-version: "1.25"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds # Cache Gradle for faster builds
@@ -174,7 +174,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.21" go-version: "1.25"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache CocoaPods # Cache CocoaPods
@@ -194,7 +194,7 @@ jobs:
working-directory: go_backend working-directory: go_backend
run: | run: |
mkdir -p ../ios/Frameworks mkdir -p ../ios/Frameworks
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework . gomobile bind -target=ios -tags ios -o ../ios/Frameworks/Gobackend.xcframework .
env: env:
CGO_ENABLED: 1 CGO_ENABLED: 1
@@ -249,23 +249,6 @@ jobs:
channel: "stable" channel: "stable"
cache: true cache: true
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
- name: Use iOS pubspec with FFmpeg plugin
run: |
cp pubspec.yaml pubspec_android_backup.yaml
cp pubspec_ios.yaml pubspec.yaml
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
# Swap FFmpeg service for iOS
- name: Use iOS FFmpeg service
run: |
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
# Update class name in the swapped file
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
echo "Swapped to iOS FFmpeg service"
- name: Get Flutter dependencies - name: Get Flutter dependencies
run: flutter pub get run: flutter pub get
@@ -441,7 +424,11 @@ jobs:
VERSION_NUM=${VERSION#v} VERSION_NUM=${VERSION#v}
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead) # Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
FULL_CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md | sed '/^---$/d') # Use tr -d '\r' to handle CRLF line endings from Windows
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
if [ -z "$FULL_CHANGELOG" ]; then if [ -z "$FULL_CHANGELOG" ]; then
CHANGELOG="See release notes on GitHub for details." CHANGELOG="See release notes on GitHub for details."
@@ -451,7 +438,9 @@ jobs:
# - `code` → <code>code</code> # - `code` → <code>code</code>
# - ### Header → <b>Header</b> # - ### Header → <b>Header</b>
# - Escape HTML special chars first # - Escape HTML special chars first
# - Remove > blockquote prefix
CHANGELOG=$(echo "$FULL_CHANGELOG" | \ CHANGELOG=$(echo "$FULL_CHANGELOG" | \
sed 's/^> //' | \
sed 's/&/\&amp;/g' | \ sed 's/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \ sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \ sed 's/>/\&gt;/g' | \
@@ -473,6 +462,8 @@ jobs:
fi fi
echo "$CHANGELOG" > /tmp/changelog.txt echo "$CHANGELOG" > /tmp/changelog.txt
echo "DEBUG: Final changelog:"
cat /tmp/changelog.txt
- name: Send to Telegram Channel - name: Send to Telegram Channel
env: env:
@@ -499,11 +490,13 @@ jobs:
MESSAGE=$(cat /tmp/telegram_message.txt) MESSAGE=$(cat /tmp/telegram_message.txt)
# Send message first (using HTML parse mode) # Send message first (using HTML parse mode)
# Use --data-urlencode for proper encoding of special chars (+, &, etc.)
# Use || true to ensure file uploads continue even if message fails
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHANNEL_ID}" \ --data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \
-d text="${MESSAGE}" \ --data-urlencode "text=${MESSAGE}" \
-d parse_mode="HTML" \ --data-urlencode "parse_mode=HTML" \
-d disable_web_page_preview="true" --data-urlencode "disable_web_page_preview=true" || true
# Upload arm64 APK to channel # Upload arm64 APK to channel
if [ -f "$ARM64_APK" ]; then if [ -f "$ARM64_APK" ]; then
+1
View File
@@ -72,3 +72,4 @@ flutter_*.log
# Development tools # Development tools
tool/ tool/
.claude/settings.local.json
+80
View File
@@ -1,5 +1,85 @@
# Changelog # Changelog
## [3.3.5] - 2026-02-01
Same as 3.3.1 but fixes crash issues caused by FFmpeg.
### Added
- **Export Failed Downloads**: Export failed downloads to TXT file for easy lookup on other platforms
- **Auto-Export Setting**: Option to automatically export failed downloads when queue finishes
### Fixed
- **FFmpeg Crash**: Fixed crash issues during M4A to MP3/Opus conversion
- **Service Selection Ignored**: Fixed bug where selecting Qobuz/Amazon from service picker was ignored and always used Tidal instead
- **iOS iCloud Drive Permission Error**: Block iCloud Drive folder selection on iOS (Go backend cannot access iCloud due to sandboxing)
### Changed
- **Amazon Fallback Only**: Amazon Music is now grayed out in service picker and can only be used as fallback provider
---
## [3.3.1] - 2026-02-01
### Added
- **Clear All Queue Button**: Cancel all queued downloads with one tap ([#96](https://github.com/zarzet/SpotiFLAC-Mobile/issues/96))
- **IDHS Fallback**: Fallback link resolver when SongLink fails (rate limited 8 req/min)
- **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))
- **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
### Changed
- **Amazon Download API**: Switched to AfkarXYZ API
- **Qobuz Download API**: Added Jumo API as fallback
- **Search Results**: Reduced artist limit from 5 to 2
### Fixed
- **MP3 Download Error 403**: Fixed 403 Forbidden error when downloading MP3 files ([#108](https://github.com/zarzet/SpotiFLAC-Mobile/issues/108))
- **Opus Cover Art**: Implemented METADATA_BLOCK_PICTURE for proper cover embedding
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/zarzet/SpotiFLAC-Mobile/issues/112))
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/zarzet/SpotiFLAC-Mobile/issues/110))
---
## [3.2.1] - 2026-01-22
### Added
- **Artist/Album + Singles Folder Structure**: Singles go inside artist folder (`Artist/Album/`, `Artist/Singles/`)
- **Embed Lyrics Button**: Manually embed online lyrics into tracks from Track Info screen (preserves synced timestamps)
- **Pause/Resume Button**: Added pause and resume controls next to "Downloading" header in History screen
- **Instrumental Detection**: Tracks marked as instrumental on lrclib.net now show "Instrumental track" instead of "Lyrics not available"
### Fixed
- **Lyrics**: Multi-artist tracks now search by primary artist first, then full string
- **Lyrics**: Metadata tags (`[ti:...]`, `[ar:...]`, `[by:...]`) no longer shown in display
- **Lyrics**: Embed button now correctly appears for tracks with online lyrics
- **Lyrics**: Manual embed preserves original timestamps instead of plain text
- **iOS**: Fixed "File not found" after 3.1.x → 3.2.0 update (container UUID migration)
- **Home Feed**: Greeting now uses device local time
- **Deezer**: Track position fallback to index+1 when API returns 0
- **Localization**: Fixed 16 ICU plural syntax warnings in Spanish & Portuguese
### Performance
- **Home Feed**: Precomputed Quick Picks section flag and reduced per-page allocations; explore state now watched by field to cut rebuilds
- **Home Recent**: Cached recent-access aggregation and limited list allocations for recent downloads
- **Settings/Theme/Recent**: Cached SharedPreferences instance to avoid repeated `getInstance()` calls
- **History/DB**: Batched iOS path migration updates to reduce write overhead
- **Download Queue**: Reduced polling allocations and avoided double-load scheduling for history
- **Misc**: Precompiled regex in share intent, update dialog, extensions error parsing, log analysis, and LRC cleanup; faster palette cache hits and log filtering
---
## [3.2.0] - 2026-01-22 ## [3.2.0] - 2026-01-22
> **Note:** Starting from v3.2.0, changelogs will be concise. > **Note:** Starting from v3.2.0, changelogs will be concise.
+16 -5
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/3257155286587a3596ad5d4380d4576a684aa3d37a5b19a615914a845fbe57f3) [![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center"> <div align="center">
@@ -52,8 +52,6 @@ Want to create your own extension? Check out the [Extension Development Guide](h
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC) ### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
> **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored.
## Telegram ## Telegram
<p align="center"> <p align="center">
@@ -61,7 +59,7 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel"> <img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a> </a>
<a href="https://t.me/spotiflacchat"> <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"> <img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a> </a>
</p> </p>
@@ -86,6 +84,14 @@ A: Yes, the app is open source and you can verify the code yourself. Each releas
**Q: Why is download not working in my country?** **Q: Why is download not working in my country?**
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region. A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
### 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._
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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>
## Disclaimer ## Disclaimer
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement. This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
@@ -100,3 +106,8 @@ You are solely responsible for:
3. Any legal consequences resulting from the misuse of this tool. 3. Any legal consequences resulting from the misuse of this tool.
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use. 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.
> [!TIP]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
Binary file not shown.
Binary file not shown.
Binary file not shown.
+74 -2
View File
@@ -5,6 +5,7 @@
-keep class io.flutter.view.** { *; } -keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; } -keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; } -keep class io.flutter.plugins.** { *; }
-keep class io.flutter.embedding.** { *; }
# Ignore missing Play Core classes (not used, but referenced by Flutter) # Ignore missing Play Core classes (not used, but referenced by Flutter)
-dontwarn com.google.android.play.core.splitcompat.** -dontwarn com.google.android.play.core.splitcompat.**
@@ -14,13 +15,22 @@
# Ignore missing javax.xml.stream (not used on Android) # Ignore missing javax.xml.stream (not used on Android)
-dontwarn javax.xml.stream.** -dontwarn javax.xml.stream.**
# Go backend (gobackend.aar) # Go backend (gobackend.aar) - CRITICAL for release builds
-keep class gobackend.** { *; } -keep class gobackend.** { *; }
-keep class go.** { *; } -keep class go.** { *; }
-keep interface gobackend.** { *; }
-keepclassmembers class gobackend.** { *; }
# Go mobile binding internals
-keep class org.golang.** { *; }
-dontwarn org.golang.**
# FFmpeg Kit # FFmpeg Kit
-keep class com.arthenica.ffmpegkit.** { *; } -keep class com.arthenica.ffmpegkit.** { *; }
-keep class com.arthenica.smartexception.** { *; } -keep class com.arthenica.smartexception.** { *; }
# FFmpeg Kit (new fork package)
-keep class com.antonkarpenko.ffmpegkit.** { *; }
-keep class com.antonkarpenko.smartexception.** { *; }
# Apache Tika (if used by FFmpeg) # Apache Tika (if used by FFmpeg)
-dontwarn org.apache.tika.** -dontwarn org.apache.tika.**
@@ -30,15 +40,77 @@
native <methods>; native <methods>;
} }
# Kotlin coroutines # Kotlin coroutines - expanded rules
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** { -keepclassmembers class kotlinx.coroutines.** {
volatile <fields>; volatile <fields>;
} }
-keepclassmembernames class kotlinx.** {
volatile <fields>;
}
-dontwarn kotlinx.coroutines.**
# Kotlin serialization
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
-dontwarn kotlin.**
-keep class kotlin.** { *; }
-keep class kotlin.Metadata { *; }
# Keep MainActivity and related classes
-keep class com.zarz.spotiflac.** { *; }
# Prevent R8 from removing metadata # Prevent R8 from removing metadata
-keepattributes *Annotation* -keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
-keepattributes Signature -keepattributes Signature
-keepattributes Exceptions -keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes EnclosingMethod
# JSON parsing (used by Go backend responses)
-keep class org.json.** { *; }
# Shared Preferences
-keep class androidx.datastore.** { *; }
-dontwarn androidx.datastore.**
# Flutter Plugins - CRITICAL: Prevent R8 from removing plugin implementations
# Path Provider
-keep class io.flutter.plugins.pathprovider.** { *; }
-keep class dev.flutter.pigeon.** { *; }
# Local Notifications
-keep class com.dexterous.** { *; }
-keep class com.dexterous.flutterlocalnotifications.** { *; }
# Receive Sharing Intent
-keep class com.kasem.receive_sharing_intent.** { *; }
# Permission Handler
-keep class com.baseflow.permissionhandler.** { *; }
# File Picker
-keep class com.mr.flutter.plugin.filepicker.** { *; }
# URL Launcher
-keep class io.flutter.plugins.urllauncher.** { *; }
# Share Plus
-keep class dev.fluttercommunity.plus.share.** { *; }
# Device Info Plus
-keep class dev.fluttercommunity.plus.device_info.** { *; }
# Open File
-keep class com.crazecoder.openfile.** { *; }
# Sqflite
-keep class com.tekartik.sqflite.** { *; }
# Dynamic Color
-keep class io.material.** { *; }
# Keep all Flutter plugin registrants
-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; }
-keep class ** extends io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
@@ -1,23 +1,154 @@
package com.zarz.spotiflac package com.zarz.spotiflac
import android.content.Intent import android.content.Intent
import android.os.Build
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import gobackend.Gobackend import gobackend.Gobackend
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.Locale
class MainActivity: FlutterActivity() { class MainActivity: FlutterActivity() {
private val CHANNEL = "com.zarz.spotiflac/backend" private val CHANNEL = "com.zarz.spotiflac/backend"
private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
companion object {
// Minimum API level we consider "safe" for Impeller (Android 10+)
private const val SAFE_API_FOR_IMPELLER = 29
// Known problematic GPU patterns (lowercase)
private val PROBLEMATIC_GPU_PATTERNS = listOf(
"adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm
"adreno (tm) 4", // Adreno 400 series - some have issues
"mali-4", // Mali-400 series - old ARM GPUs
"mali-t6", // Mali-T600 series
"mali-t7", // Mali-T700 series (some)
"powervr sgx", // PowerVR SGX series - old Imagination GPUs
"powervr ge8320", // PowerVR GE8320 - known issues
"gc1000", // Vivante GC1000
"gc2000", // Vivante GC2000
)
// Known problematic chipsets/hardware (lowercase)
private val PROBLEMATIC_CHIPSETS = listOf(
"mt6762", // MediaTek Helio P22 with PowerVR GE8320
"mt6765", // MediaTek Helio P35 with PowerVR GE8320
"mt8768", // MediaTek tablet chip
"mp0873", // MediaTek variant
"msm8974", // Snapdragon 800/801 with Adreno 330
"msm8226", // Snapdragon 400 with Adreno 305
"msm8926", // Snapdragon 400 with Adreno 305
"apq8084", // Snapdragon 805 (some issues)
)
// Known problematic device models (lowercase)
private val PROBLEMATIC_MODELS = listOf(
"sm-t220", // Samsung Tab A7 Lite
"sm-t225", // Samsung Tab A7 Lite LTE
"hammerhead", // Nexus 5 (Adreno 330)
)
}
/**
* Override Flutter shell args to disable Impeller on problematic devices.
* This is called before the Flutter engine starts.
*/
override fun getFlutterShellArgs(): FlutterShellArgs {
val args = super.getFlutterShellArgs()
if (shouldDisableImpeller()) {
// Log for debugging
android.util.Log.i("SpotiFLAC", "Legacy/problematic GPU detected: Disabling Impeller for ${Build.MODEL}")
android.util.Log.i("SpotiFLAC", "Device: ${Build.MANUFACTURER} ${Build.MODEL}, SDK: ${Build.VERSION.SDK_INT}")
android.util.Log.i("SpotiFLAC", "Hardware: ${Build.HARDWARE}, Board: ${Build.BOARD}")
// Disable Impeller, forcing Skia renderer
args.add("--enable-impeller=false")
} else {
android.util.Log.i("SpotiFLAC", "Using Impeller renderer for ${Build.MODEL}")
}
return args
}
/**
* Check if device should use Skia instead of Impeller.
* Returns true for devices with old/problematic GPUs or old Android versions.
*/
private fun shouldDisableImpeller(): Boolean {
val hardware = Build.HARDWARE.lowercase(Locale.ROOT)
val board = Build.BOARD.lowercase(Locale.ROOT)
val model = Build.MODEL.lowercase(Locale.ROOT)
val device = Build.DEVICE.lowercase(Locale.ROOT)
// 1. Check for explicitly problematic device models
for (problematicModel in PROBLEMATIC_MODELS) {
if (model.contains(problematicModel) || device.contains(problematicModel)) {
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
return true
}
}
// 2. Check for problematic chipsets
for (chipset in PROBLEMATIC_CHIPSETS) {
if (hardware.contains(chipset) || board.contains(chipset)) {
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
return true
}
}
// 3. For Android < 10 (API 29), be more aggressive about disabling Impeller
if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) {
// For older Android, check GPU renderer if available
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
// Check for known problematic GPUs
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) {
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
return true
}
}
// For very old Android (< 8.0), always use Skia as Vulkan support is spotty
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
return true
}
}
// 4. For Android 10+, still check for known problematic GPUs
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) {
android.util.Log.i("SpotiFLAC", "Matched problematic GPU: $pattern")
return true
}
}
return false
}
/**
* Try to get GPU renderer string.
* Note: This may return empty on some devices before OpenGL context is created.
*/
private fun getGpuRenderer(): String {
return try {
// This might not work before GL context is created,
// but worth trying for additional detection
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
} catch (e: Exception) {
""
}
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
// Update the intent so receive_sharing_intent can access the new data // Update the intent so receive_sharing_intent can access the new data
@@ -278,9 +409,10 @@ class MainActivity: FlutterActivity() {
"searchDeezerAll" -> { "searchDeezerAll" -> {
val query = call.argument<String>("query") ?: "" val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15 val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 3 val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong()) Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
} }
result.success(response) result.success(response)
} }
@@ -767,37 +899,5 @@ class MainActivity: FlutterActivity() {
} }
} }
} }
// FFmpeg method channel
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FFMPEG_CHANNEL).setMethodCallHandler { call, result ->
scope.launch {
try {
when (call.method) {
"execute" -> {
val command = call.argument<String>("command") ?: ""
val session = withContext(Dispatchers.IO) {
FFmpegKit.execute(command)
}
val returnCode = session.returnCode
val output = session.output ?: ""
result.success(mapOf(
"success" to ReturnCode.isSuccess(returnCode),
"returnCode" to (returnCode?.value ?: -1),
"output" to output
))
}
"getVersion" -> {
val session = withContext(Dispatchers.IO) {
FFmpegKit.execute("-version")
}
result.success(session.output ?: "unknown")
}
else -> result.notImplemented()
}
} catch (e: Exception) {
result.error("FFMPEG_ERROR", e.message, null)
}
}
}
} }
} }
-335
View File
@@ -1,335 +0,0 @@
import 'dart:io';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg');
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
class FFmpegServiceIOS {
/// Execute FFmpeg command and return result
static Future<FFmpegResultIOS> _execute(String command) async {
try {
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
final output = await session.getOutput() ?? '';
return FFmpegResultIOS(
success: ReturnCode.isSuccess(returnCode),
returnCode: returnCode?.getValue() ?? -1,
output: output,
);
} catch (e) {
_log.e('FFmpeg execute error: $e');
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
}
}
/// Convert M4A (DASH segments) to FLAC
static Future<String?> convertM4aToFlac(String inputPath) async {
final outputPath = inputPath.replaceAll('.m4a', '.flac');
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
final result = await _execute(command);
if (result.success) {
try {
await File(inputPath).delete();
} catch (_) {}
return outputPath;
}
_log.e('M4A to FLAC conversion failed: ${result.output}');
return null;
}
/// Convert FLAC to MP3
/// If deleteOriginal is true, deletes the FLAC file after conversion
static Future<String?> convertFlacToMp3(
String inputPath, {
String bitrate = '320k',
bool deleteOriginal = true,
}) async {
// Convert in same folder, just change extension
final outputPath = inputPath.replaceAll('.flac', '.mp3');
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
final result = await _execute(command);
if (result.success) {
// Delete original FLAC if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();
} catch (_) {}
}
return outputPath;
}
_log.e('FLAC to MP3 conversion failed: ${result.output}');
return null;
}
/// Convert FLAC to M4A
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
final dir = File(inputPath).parent.path;
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}M4A';
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
String command;
if (codec == 'alac') {
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
} else {
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
}
final result = await _execute(command);
if (result.success) return outputPath;
_log.e('FLAC to M4A conversion failed: ${result.output}');
return null;
}
/// Embed cover art to FLAC file
static Future<String?> embedCover(String flacPath, String coverPath) async {
final tempOutput = '$flacPath.tmp';
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
final result = await _execute(command);
if (result.success) {
try {
await File(flacPath).delete();
await File(tempOutput).rename(flacPath);
return flacPath;
} catch (e) {
_log.e('Failed to replace file after cover embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) await tempFile.delete();
} catch (_) {}
_log.e('Cover embed failed: ${result.output}');
return null;
}
/// Embed metadata and cover art to FLAC file
/// Returns the file path on success, null on failure
static Future<String?> embedMetadata({
required String flacPath,
String? coverPath,
Map<String, String>? metadata,
}) async {
final tempOutput = '$flacPath.tmp';
// Construct command
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$flacPath" ');
// Add cover input if available
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
// Map audio stream
cmdBuffer.write('-map 0:a ');
// Map cover stream if available
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v copy ');
cmdBuffer.write('-disposition:v attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
// Copy audio codec (don't re-encode)
cmdBuffer.write('-c:a copy ');
// Add text metadata
if (metadata != null) {
metadata.forEach((key, value) {
// Sanitize value: escape double quotes
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('"$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg command: $command');
final result = await _execute(command);
if (result.success) {
try {
await File(flacPath).delete();
await File(tempOutput).rename(flacPath);
return flacPath;
} catch (e) {
_log.e('Failed to replace file after metadata embed: $e');
return null;
}
}
// Clean up temp file if exists
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
_log.e('Metadata/Cover embed failed: ${result.output}');
return null;
}
/// Embed metadata and cover art to MP3 file using ID3v2 tags
/// Returns the file path on success, null on failure
static Future<String?> embedMetadataToMp3({
required String mp3Path,
String? coverPath,
Map<String, String>? metadata,
}) async {
final tempOutput = '$mp3Path.tmp';
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$mp3Path" ');
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v:0 copy ');
cmdBuffer.write('-id3v2_version 3 ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
final id3Metadata = _convertToId3Tags(metadata);
id3Metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg MP3 embed command: $command');
final result = await _execute(command);
if (result.success) {
try {
await File(mp3Path).delete();
await File(tempOutput).rename(mp3Path);
_log.d('MP3 metadata embedded successfully');
return mp3Path;
} catch (e) {
_log.e('Failed to replace MP3 file after metadata embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
return null;
}
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
final id3Map = <String, String>{};
for (final entry in vorbisMetadata.entries) {
final key = entry.key.toUpperCase();
final value = entry.value;
// Map Vorbis comments to ID3v2 frame names
switch (key) {
case 'TITLE':
id3Map['title'] = value;
break;
case 'ARTIST':
id3Map['artist'] = value;
break;
case 'ALBUM':
id3Map['album'] = value;
break;
case 'ALBUMARTIST':
id3Map['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
id3Map['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
id3Map['disc'] = value;
break;
case 'DATE':
case 'YEAR':
id3Map['date'] = value;
break;
case 'ISRC':
id3Map['TSRC'] = value; // ID3v2 ISRC frame
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value;
break;
default:
// Pass through other tags as-is
id3Map[key.toLowerCase()] = value;
}
}
return id3Map;
}
/// Check if FFmpeg is available
static Future<bool> isAvailable() async {
try {
final session = await FFmpegKit.execute('-version');
final returnCode = await session.getReturnCode();
return ReturnCode.isSuccess(returnCode);
} catch (e) {
return false;
}
}
/// Get FFmpeg version info
static Future<String?> getVersion() async {
try {
final session = await FFmpegKit.execute('-version');
return await session.getOutput();
} catch (e) {
return null;
}
}
}
class FFmpegResultIOS {
final bool success;
final int returnCode;
final String output;
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
}
+90 -307
View File
@@ -3,7 +3,6 @@ package gobackend
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -12,79 +11,29 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
type AmazonDownloader struct { type AmazonDownloader struct {
client *http.Client client *http.Client
regions []string
lastAPICallTime time.Time
apiCallCount int
apiCallResetTime time.Time
} }
var ( var (
globalAmazonDownloader *AmazonDownloader globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once amazonDownloaderOnce sync.Once
amazonRateLimitMu sync.Mutex
) )
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint // AfkarXYZResponse is the response from AfkarXYZ API
type DoubleDoubleSubmitResponse struct { type AfkarXYZResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
ID string `json:"id"` Data struct {
} DirectLink string `json:"direct_link"`
FileName string `json:"file_name"`
type DoubleDoubleStatusResponse struct { FileSize int64 `json:"file_size"`
Status string `json:"status"` } `json:"data"`
FriendlyStatus string `json:"friendlyStatus"`
URL string `json:"url"`
Current struct {
Name string `json:"name"`
Artist string `json:"artist"`
} `json:"current"`
}
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
if normExpected == normFound {
return true
}
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst {
return true
}
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
}
expectedASCII := amazonIsASCIIString(expectedArtist)
foundASCII := amazonIsASCIIString(foundArtist)
if expectedASCII != foundASCII {
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
}
return false
} }
func amazonIsASCIIString(s string) bool { func amazonIsASCIIString(s string) bool {
@@ -99,234 +48,64 @@ func amazonIsASCIIString(s string) bool {
func NewAmazonDownloader() *AmazonDownloader { func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() { amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{ globalAmazonDownloader = &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC client: NewHTTPClientWithTimeout(120 * time.Second),
regions: []string{"us", "eu"}, // Same regions as PC
apiCallResetTime: time.Now(),
} }
}) })
return globalAmazonDownloader return globalAmazonDownloader
} }
// waitForRateLimit implements rate limiting similar to PC version func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
func (a *AmazonDownloader) waitForRateLimit() { apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
amazonRateLimitMu.Lock()
defer amazonRateLimitMu.Unlock()
now := time.Now() GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
if now.Sub(a.apiCallResetTime) >= time.Minute { req, err := http.NewRequest("GET", apiURL, nil)
a.apiCallCount = 0 if err != nil {
a.apiCallResetTime = now return "", "", fmt.Errorf("failed to create request: %w", err)
} }
if a.apiCallCount >= 9 { req.Header.Set("User-Agent", getRandomUserAgent())
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
if waitTime > 0 { resp, err := a.client.Do(req)
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) if err != nil {
time.Sleep(waitTime) return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
a.apiCallCount = 0 }
a.apiCallResetTime = time.Now() defer resp.Body.Close()
}
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
} }
if !a.lastAPICallTime.IsZero() { body, err := io.ReadAll(resp.Body)
timeSinceLastCall := now.Sub(a.lastAPICallTime) if err != nil {
minDelay := 7 * time.Second return "", "", fmt.Errorf("failed to read response: %w", err)
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
} }
a.lastAPICallTime = time.Now() var apiResp AfkarXYZResponse
a.apiCallCount++ if err := json.Unmarshal(body, &apiResp); err != nil {
} return "", "", fmt.Errorf("failed to decode response: %w", err)
// Uses same service as PC version (doubledouble.top)
func (a *AmazonDownloader) GetAvailableAPIs() []string {
// DoubleDouble service regions (same as PC)
// Format: https://{region}.doubledouble.top
var apis []string
for _, region := range a.regions {
apis = append(apis, fmt.Sprintf("https://%s.doubledouble.top", region))
}
return apis
}
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
// This uses submit → poll → download mechanism
// Internal function - not exported to gomobile
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) (string, string, string, error) {
var lastError error
for _, region := range a.regions {
GoLog("[Amazon] Trying region: %s...\n", region)
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
encodedURL := url.QueryEscape(amazonURL)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
a.waitForRateLimit()
req, err := http.NewRequest("GET", submitURL, nil)
if err != nil {
lastError = fmt.Errorf("failed to create request: %w", err)
continue
}
req.Header.Set("User-Agent", getRandomUserAgent())
fmt.Println("[Amazon] Submitting download request...")
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
var resp *http.Response
maxRetries := 3
for retry := 0; retry < maxRetries; retry++ {
resp, err = a.client.Do(req)
if err != nil {
lastError = fmt.Errorf("failed to submit request: %w", err)
break
}
if resp.StatusCode == 429 { // Too Many Requests
resp.Body.Close()
if retry < maxRetries-1 {
waitTime := 15 * time.Second
GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
time.Sleep(waitTime)
continue
}
lastError = fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
break
}
if resp.StatusCode != 200 {
resp.Body.Close()
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
break
}
// Success - break retry loop
break
}
if err != nil || lastError != nil {
if resp != nil {
resp.Body.Close()
}
continue
}
var submitResp DoubleDoubleSubmitResponse
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
resp.Body.Close()
lastError = fmt.Errorf("failed to decode submit response: %w", err)
continue
}
resp.Body.Close()
if !submitResp.Success || submitResp.ID == "" {
lastError = fmt.Errorf("submit request failed")
continue
}
downloadID := submitResp.ID
GoLog("[Amazon] Download ID: %s\n", downloadID)
// Step 2: Poll for completion
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
fmt.Println("[Amazon] Waiting for download to complete...")
maxWait := 300 * time.Second // 5 minutes max wait
elapsed := time.Duration(0)
pollInterval := 3 * time.Second
for elapsed < maxWait {
time.Sleep(pollInterval)
elapsed += pollInterval
statusReq, err := http.NewRequest("GET", statusURL, nil)
if err != nil {
continue
}
statusReq.Header.Set("User-Agent", getRandomUserAgent())
statusResp, err := a.client.Do(statusReq)
if err != nil {
fmt.Printf("\r[Amazon] Status check failed, retrying...")
continue
}
if statusResp.StatusCode != 200 {
statusResp.Body.Close()
fmt.Printf("\r[Amazon] Status check failed (status %d), retrying...", statusResp.StatusCode)
continue
}
var status DoubleDoubleStatusResponse
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
statusResp.Body.Close()
fmt.Printf("\r[Amazon] Invalid JSON response, retrying...")
continue
}
statusResp.Body.Close()
if status.Status == "done" {
fmt.Println("\n[Amazon] Download ready!")
fileURL := status.URL
if strings.HasPrefix(fileURL, "./") {
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
} else if strings.HasPrefix(fileURL, "/") {
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
}
trackName := status.Current.Name
artist := status.Current.Artist
GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
return fileURL, trackName, artist, nil
} else if status.Status == "error" {
errorMsg := status.FriendlyStatus
if errorMsg == "" {
errorMsg = "Unknown error"
}
lastError = fmt.Errorf("processing failed: %s", errorMsg)
break
} else {
// Still processing
friendlyStatus := status.FriendlyStatus
if friendlyStatus == "" {
friendlyStatus = status.Status
}
fmt.Printf("\r[Amazon] %s...", friendlyStatus)
}
}
if elapsed >= maxWait {
lastError = fmt.Errorf("download timeout")
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
continue
}
if lastError != nil {
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
}
} }
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError) if !apiResp.Success || apiResp.Data.DirectLink == "" {
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
}
fileName := apiResp.Data.FileName
if fileName == "" {
fileName = "track.flac"
}
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
} }
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background() ctx := context.Background()
// Initialize item progress (required for all downloads)
if itemID != "" { if itemID != "" {
StartItemProgress(itemID) StartItemProgress(itemID)
defer CompleteItemProgress(itemID) defer CompleteItemProgress(itemID)
@@ -378,7 +157,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
written, err = io.Copy(bufWriter, resp.Body) written, err = io.Copy(bufWriter, resp.Body)
} }
// Flush buffer before checking for errors
flushErr := bufWriter.Flush() flushErr := bufWriter.Flush()
closeErr := out.Close() closeErr := out.Close()
@@ -398,13 +176,12 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
return fmt.Errorf("failed to close file: %w", closeErr) return fmt.Errorf("failed to close file: %w", closeErr)
} }
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize { if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath) os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
} }
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024)) GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
return nil return nil
} }
@@ -422,7 +199,6 @@ type AmazonDownloadResult struct {
ISRC string ISRC string
} }
// Uses DoubleDouble service (same as PC version)
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader() downloader := NewAmazonDownloader()
@@ -434,8 +210,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
var availability *TrackAvailability var availability *TrackAvailability
var err error var err error
if strings.HasPrefix(req.SpotifyID, "deezer:") { if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID) GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID) availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" { } else if req.SpotifyID != "" {
@@ -458,21 +233,15 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
} }
} }
// Download using DoubleDouble service (same as PC) // Download using AfkarXYZ API
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir) downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL)
if err != nil { if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
} }
// Verify artist matches GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
}
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName) filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName, "title": req.TrackName,
"artist": req.ArtistName, "artist": req.ArtistName,
"album": req.AlbumName, "album": req.AlbumName,
@@ -519,14 +288,13 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
SetItemFinalizing(req.ItemID) SetItemFinalizing(req.ItemID)
} }
// Log track info from DoubleDouble (for debugging)
if trackName != "" && artistName != "" {
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
}
existingMeta, metaErr := ReadMetadata(outputPath) existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber actualDiscNum := req.DiscNumber
actualDate := req.ReleaseDate
actualAlbum := req.AlbumName
actualTitle := req.TrackName
actualArtist := req.ArtistName
if metaErr == nil && existingMeta != nil { if metaErr == nil && existingMeta != nil {
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) { if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
@@ -537,40 +305,55 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
actualDiscNum = existingMeta.DiscNumber actualDiscNum = existingMeta.DiscNumber
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.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)
} }
// Embed metadata using Spotify data (more accurate than DoubleDouble)
// But preserve track/disc numbers from file if they were better
metadata := Metadata{ metadata := Metadata{
Title: req.TrackName, Title: actualTitle,
Artist: req.ArtistName, Artist: actualArtist,
Album: req.AlbumName, Album: actualAlbum,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate, Date: actualDate,
TrackNumber: actualTrackNum, TrackNumber: actualTrackNum,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNum, DiscNumber: actualDiscNum,
ISRC: req.ISRC, ISRC: req.ISRC,
Genre: req.Genre, // From Deezer album metadata Genre: req.Genre,
Label: req.Label, // From Deezer album metadata Label: req.Label,
Copyright: req.Copyright, // From Deezer album metadata Copyright: req.Copyright,
} }
// Use cover data from parallel fetch
var coverData []byte var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil { if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
coverData = parallelResult.CoverData coverData = parallelResult.CoverData
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} else {
existingCover, coverErr := ExtractCoverArt(outputPath)
if coverErr == nil && len(existingCover) > 0 {
coverData = existingCover
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
} else {
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
}
} }
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err) GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
} }
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode lyricsMode := req.LyricsMode
if lyricsMode == "" { if lyricsMode == "" {
lyricsMode = "embed" // default lyricsMode = "embed"
} }
if lyricsMode == "external" || lyricsMode == "both" { if lyricsMode == "external" || lyricsMode == "both" {
@@ -587,14 +370,14 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else { } else {
fmt.Println("[Amazon] Lyrics embedded successfully") GoLog("[Amazon] Lyrics embedded successfully\n")
} }
} }
} else if req.EmbedLyrics { } else if req.EmbedLyrics {
fmt.Println("[Amazon] No lyrics available from parallel fetch") GoLog("[Amazon] No lyrics available from parallel fetch\n")
} }
fmt.Println("[Amazon] Downloaded successfully from Amazon Music") GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
quality, err := GetAudioQuality(outputPath) quality, err := GetAudioQuality(outputPath)
if err != nil { if err != nil {
+294 -97
View File
@@ -55,7 +55,7 @@ func GetDeezerClient() *DeezerClient {
type deezerTrack struct { type deezerTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Duration int `json:"duration"` // in seconds Duration int `json:"duration"`
TrackPosition int `json:"track_position"` TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"` DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
@@ -121,7 +121,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
AlbumArtist: track.Artist.Name, AlbumArtist: track.Artist.Name,
DurationMS: track.Duration * 1000, DurationMS: track.Duration * 1000,
Images: albumImage, Images: albumImage,
ReleaseDate: releaseDate, // Added this ReleaseDate: releaseDate,
TrackNumber: track.TrackPosition, TrackNumber: track.TrackPosition,
DiscNumber: track.DiskNumber, DiscNumber: track.DiskNumber,
ExternalURL: track.Link, ExternalURL: track.Link,
@@ -182,11 +182,38 @@ type deezerPlaylistFull struct {
} `json:"tracks"` } `json:"tracks"`
} }
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit) albumLimit := 5
playlistLimit := 5
if filter != "" {
switch filter {
case "track":
trackLimit = 50
artistLimit = 0
albumLimit = 0
playlistLimit = 0
case "artist":
trackLimit = 0
artistLimit = 20
albumLimit = 0
playlistLimit = 0
case "album":
trackLimit = 0
artistLimit = 0
albumLimit = 20
playlistLimit = 0
case "playlist":
trackLimit = 0
artistLimit = 0
albumLimit = 0
playlistLimit = 20
}
}
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d:%d:%d:%s", query, trackLimit, artistLimit, albumLimit, playlistLimit, filter)
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
@@ -197,69 +224,189 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
result := &SearchAllResult{ result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0, trackLimit), Tracks: make([]TrackMetadata, 0, trackLimit),
Artists: make([]SearchArtistResult, 0, artistLimit), Artists: make([]SearchArtistResult, 0, artistLimit),
Albums: make([]SearchAlbumResult, 0, albumLimit),
Playlists: make([]SearchPlaylistResult, 0, playlistLimit),
} }
// Search tracks - NO ISRC fetch for performance if trackLimit > 0 {
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit) trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL) GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
var trackResp struct { var trackResp struct {
Data []deezerTrack `json:"data"` Data []deezerTrack `json:"data"`
Error *struct { Error *struct {
Type string `json:"type"` Type string `json:"type"`
Message string `json:"message"` Message string `json:"message"`
Code int `json:"code"` Code int `json:"code"`
} `json:"error"` } `json:"error"`
} }
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil { if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
GoLog("[Deezer] Track search failed: %v\n", err) GoLog("[Deezer] Track search failed: %v\n", err)
return nil, fmt.Errorf("deezer track search failed: %w", err) return nil, fmt.Errorf("deezer track search failed: %w", err)
} }
if trackResp.Error != nil { if trackResp.Error != nil {
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message) GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code) return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
} }
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data)) GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
for _, track := range trackResp.Data { for _, track := range trackResp.Data {
result.Tracks = append(result.Tracks, c.convertTrack(track)) result.Tracks = append(result.Tracks, c.convertTrack(track))
}
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
var artistResp struct {
Data []deezerArtist `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
if artistResp.Error != nil {
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
} else {
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
for _, artist := range artistResp.Data {
result.Artists = append(result.Artists, SearchArtistResult{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: c.getBestArtistImage(artist),
Followers: artist.NbFan,
Popularity: 0,
})
}
} }
} else {
GoLog("[Deezer] Artist search failed: %v\n", err)
} }
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists)) if artistLimit > 0 {
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
var artistResp struct {
Data []deezerArtist `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
if artistResp.Error != nil {
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
} else {
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
for _, artist := range artistResp.Data {
result.Artists = append(result.Artists, SearchArtistResult{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: c.getBestArtistImage(artist),
Followers: artist.NbFan,
Popularity: 0,
})
}
}
} else {
GoLog("[Deezer] Artist search failed: %v\n", err)
}
}
if albumLimit > 0 {
albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit)
GoLog("[Deezer] Fetching albums from: %s\n", albumURL)
var albumResp struct {
Data []struct {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
NbTracks int `json:"nb_tracks"`
ReleaseDate string `json:"release_date"`
RecordType string `json:"record_type"`
Artist deezerArtist `json:"artist"`
} `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, albumURL, &albumResp); err == nil {
if albumResp.Error != nil {
GoLog("[Deezer] Album API error: type=%s, code=%d, message=%s\n", albumResp.Error.Type, albumResp.Error.Code, albumResp.Error.Message)
} else {
GoLog("[Deezer] Got %d albums from API\n", len(albumResp.Data))
for _, album := range albumResp.Data {
coverURL := album.CoverXL
if coverURL == "" {
coverURL = album.CoverBig
}
if coverURL == "" {
coverURL = album.CoverMedium
}
if coverURL == "" {
coverURL = album.Cover
}
albumType := album.RecordType
if albumType == "compile" {
albumType = "compilation"
}
result.Albums = append(result.Albums, SearchAlbumResult{
ID: fmt.Sprintf("deezer:%d", album.ID),
Name: album.Title,
Artists: album.Artist.Name,
Images: coverURL,
ReleaseDate: album.ReleaseDate,
TotalTracks: album.NbTracks,
AlbumType: albumType,
})
}
}
} else {
GoLog("[Deezer] Album search failed: %v\n", err)
}
}
if playlistLimit > 0 {
playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit)
GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL)
var playlistResp struct {
Data []struct {
ID int64 `json:"id"`
Title string `json:"title"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbTracks int `json:"nb_tracks"`
User struct {
Name string `json:"name"`
} `json:"user"`
} `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, playlistURL, &playlistResp); err == nil {
if playlistResp.Error != nil {
GoLog("[Deezer] Playlist API error: type=%s, code=%d, message=%s\n", playlistResp.Error.Type, playlistResp.Error.Code, playlistResp.Error.Message)
} else {
GoLog("[Deezer] Got %d playlists from API\n", len(playlistResp.Data))
for _, playlist := range playlistResp.Data {
pictureURL := playlist.PictureXL
if pictureURL == "" {
pictureURL = playlist.PictureBig
}
if pictureURL == "" {
pictureURL = playlist.PictureMedium
}
if pictureURL == "" {
pictureURL = playlist.Picture
}
result.Playlists = append(result.Playlists, SearchPlaylistResult{
ID: fmt.Sprintf("deezer:%d", playlist.ID),
Name: playlist.Title,
Owner: playlist.User.Name,
Images: pictureURL,
TotalTracks: playlist.NbTracks,
})
}
}
} else {
GoLog("[Deezer] Playlist search failed: %v\n", err)
}
}
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
c.cacheMu.Lock() c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{ c.searchCache[cacheKey] = &cacheEntry{
@@ -271,7 +418,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
return result, nil return result, nil
} }
// GetTrack fetches a single track by Deezer ID
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) { func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID) trackURL := fmt.Sprintf(deezerTrackURL, trackID)
@@ -285,7 +431,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
}, nil }, nil
} }
// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) { func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() { if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
@@ -311,7 +456,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
artistName = strings.Join(names, ", ") artistName = strings.Join(names, ", ")
} }
// Extract genres as comma-separated string
var genres []string var genres []string
for _, g := range album.Genres.Data { for _, g := range album.Genres.Data {
if g.Name != "" { if g.Name != "" {
@@ -327,23 +471,60 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
Artists: artistName, Artists: artistName,
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID), ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
Images: albumImage, Images: albumImage,
Genre: genreStr, // From Deezer album Genre: genreStr,
Label: album.Label, // From Deezer album Label: album.Label,
} }
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data) allTracks := album.Tracks.Data
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data)) if album.NbTracks > len(allTracks) {
// Normalize record_type (Deezer uses "compile" instead of "compilation") GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerAlbumURL, albumID), len(allTracks))
for len(allTracks) < album.NbTracks {
var tracksResp struct {
Data []deezerTrack `json:"data"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
GoLog("[Deezer] Warning: failed to fetch album tracks page: %v", err)
break
}
if len(tracksResp.Data) == 0 {
break
}
allTracks = append(allTracks, tracksResp.Data...)
if tracksResp.Next == "" {
break
}
tracksURL = tracksResp.Next
}
GoLog("[Deezer] Fetched total %d tracks for album", len(allTracks))
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
albumType := album.RecordType albumType := album.RecordType
if albumType == "compile" { if albumType == "compile" {
albumType = "compilation" albumType = "compilation"
} }
for _, track := range album.Tracks.Data { for i, track := range allTracks {
trackIDStr := fmt.Sprintf("%d", track.ID) trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr] isrc := isrcMap[trackIDStr]
trackNum := track.TrackPosition
if trackNum == 0 {
trackNum = i + 1
}
tracks = append(tracks, AlbumTrackMetadata{ tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID), SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name, Artists: track.Artist.Name,
@@ -353,7 +534,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
DurationMS: track.Duration * 1000, DurationMS: track.Duration * 1000,
Images: albumImage, Images: albumImage,
ReleaseDate: album.ReleaseDate, ReleaseDate: album.ReleaseDate,
TrackNumber: track.TrackPosition, TrackNumber: trackNum,
TotalTracks: album.NbTracks, TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber, DiscNumber: track.DiskNumber,
ExternalURL: track.Link, ExternalURL: track.Link,
@@ -386,7 +567,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Fetch artist info
artistURL := fmt.Sprintf(deezerArtistURL, artistID) artistURL := fmt.Sprintf(deezerArtistURL, artistID)
var artist deezerArtistFull var artist deezerArtistFull
if err := c.getJSON(ctx, artistURL, &artist); err != nil { if err := c.getJSON(ctx, artistURL, &artist); err != nil {
@@ -401,7 +581,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
Popularity: 0, Popularity: 0,
} }
// Fetch artist albums
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID)) albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
var albumsResp struct { var albumsResp struct {
Data []struct { Data []struct {
@@ -413,7 +592,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
CoverMedium string `json:"cover_medium"` CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"` CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
RecordType string `json:"record_type"` // album, single, ep, compile RecordType string `json:"record_type"`
} `json:"data"` } `json:"data"`
} }
@@ -485,10 +664,43 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
info.Owner.Name = playlist.Title info.Owner.Name = playlist.Title
info.Owner.Images = playlistImage info.Owner.Images = playlistImage
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data) allTracks := playlist.Tracks.Data
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data)) if playlist.NbTracks > len(allTracks) {
for _, track := range playlist.Tracks.Data { GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerPlaylistURL, playlistID), len(allTracks))
for len(allTracks) < playlist.NbTracks {
var tracksResp struct {
Data []deezerTrack `json:"data"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
GoLog("[Deezer] Warning: failed to fetch playlist tracks page: %v", err)
break
}
if len(tracksResp.Data) == 0 {
break
}
allTracks = append(allTracks, tracksResp.Data...)
if tracksResp.Next == "" {
break
}
tracksURL = tracksResp.Next
}
GoLog("[Deezer] Fetched total %d tracks for playlist", len(allTracks))
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
for _, track := range allTracks {
albumImage := track.Album.CoverXL albumImage := track.Album.CoverXL
if albumImage == "" { if albumImage == "" {
albumImage = track.Album.CoverBig albumImage = track.Album.CoverBig
@@ -559,7 +771,6 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
return &track, nil return &track, nil
} }
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string { func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
result := make(map[string]string, len(tracks)) result := make(map[string]string, len(tracks))
var resultMu sync.Mutex var resultMu sync.Mutex
@@ -598,7 +809,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return result return result
} }
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, deezerMaxParallelISRC) sem := make(chan struct{}, deezerMaxParallelISRC)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -620,7 +830,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return return
} }
// Store in result and cache
resultMu.Lock() resultMu.Lock()
result[trackIDStr] = fullTrack.ISRC result[trackIDStr] = fullTrack.ISRC
resultMu.Unlock() resultMu.Unlock()
@@ -635,7 +844,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return result return result
} }
// Use this when you need ISRC for download
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) { func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
c.cacheMu.RLock() c.cacheMu.RLock()
if isrc, ok := c.isrcCache[trackID]; ok { if isrc, ok := c.isrcCache[trackID]; ok {
@@ -696,11 +904,10 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
} }
type AlbumExtendedMetadata struct { type AlbumExtendedMetadata struct {
Genre string // Comma-separated list of genres Genre string
Label string // Record label name Label string
} }
// Uses the album ID from a track to fetch extended metadata
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) { func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
if albumID == "" { if albumID == "" {
return nil, fmt.Errorf("empty album ID") return nil, fmt.Errorf("empty album ID")
@@ -745,7 +952,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
return result, nil return result, nil
} }
// GetTrackAlbumID fetches the album ID for a Deezer track
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) { func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID) trackURL := fmt.Sprintf(deezerTrackURL, trackID)
@@ -757,7 +963,6 @@ func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (str
return fmt.Sprintf("%d", track.Album.ID), nil return fmt.Sprintf("%d", track.Album.ID), nil
} }
// This is a convenience function that first gets the album ID, then fetches album metadata
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) { func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
albumID, err := c.GetTrackAlbumID(ctx, trackID) albumID, err := c.GetTrackAlbumID(ctx, trackID)
if err != nil { if err != nil {
@@ -767,29 +972,22 @@ func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID
return c.GetAlbumExtendedMetadata(ctx, albumID) return c.GetAlbumExtendedMetadata(ctx, albumID)
} }
// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label)
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) { func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
if isrc == "" { if isrc == "" {
return nil, fmt.Errorf("empty ISRC") return nil, fmt.Errorf("empty ISRC")
} }
// First, search for track by ISRC
track, err := c.SearchByISRC(ctx, isrc) track, err := c.SearchByISRC(ctx, isrc)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to find track by ISRC: %w", err) return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
} }
// SpotifyID contains "deezer:123" format, extract the ID deezerID := strings.TrimPrefix(track.SpotifyID, "deezer:")
deezerID := track.SpotifyID
if strings.HasPrefix(deezerID, "deezer:") {
deezerID = strings.TrimPrefix(deezerID, "deezer:")
}
if deezerID == "" { if deezerID == "" {
return nil, fmt.Errorf("track found but no Deezer ID") return nil, fmt.Errorf("track found but no Deezer ID")
} }
// Then fetch extended metadata using the Deezer track ID
return c.GetExtendedMetadataByTrackID(ctx, deezerID) return c.GetExtendedMetadataByTrackID(ctx, deezerID)
} }
@@ -819,7 +1017,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
return json.Unmarshal(body, dst) return json.Unmarshal(body, dst)
} }
// parseDeezerURL is internal function, returns type and ID
func parseDeezerURL(input string) (string, string, error) { func parseDeezerURL(input string) (string, string, error) {
trimmed := strings.TrimSpace(input) trimmed := strings.TrimSpace(input)
if trimmed == "" { if trimmed == "" {
+1 -18
View File
@@ -10,7 +10,6 @@ import (
"time" "time"
) )
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
type ISRCIndex struct { type ISRCIndex struct {
index map[string]string // ISRC (uppercase) -> file path index map[string]string // ISRC (uppercase) -> file path
outputDir string outputDir string
@@ -25,8 +24,6 @@ var (
isrcIndexTTL = 5 * time.Minute isrcIndexTTL = 5 * time.Minute
) )
// GetISRCIndex returns or builds an ISRC index for the given directory
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
func GetISRCIndex(outputDir string) *ISRCIndex { func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first // Fast path: check cache first
isrcIndexCacheMu.RLock() isrcIndexCacheMu.RLock()
@@ -56,7 +53,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
return buildISRCIndex(outputDir) return buildISRCIndex(outputDir)
} }
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
func buildISRCIndex(outputDir string) *ISRCIndex { func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{ idx := &ISRCIndex{
index: make(map[string]string), index: make(map[string]string),
@@ -91,7 +87,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return nil return nil
}) })
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n", fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond)) outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
isrcIndexCacheMu.Lock() isrcIndexCacheMu.Lock()
@@ -113,7 +109,6 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
return path, exists return path, exists
} }
// remove deletes an ISRC entry from the index (internal use)
func (idx *ISRCIndex) remove(isrc string) { func (idx *ISRCIndex) remove(isrc string) {
if isrc == "" { if isrc == "" {
return return
@@ -125,14 +120,11 @@ func (idx *ISRCIndex) remove(isrc string) {
delete(idx.index, strings.ToUpper(isrc)) delete(idx.index, strings.ToUpper(isrc))
} }
// Lookup checks if an ISRC exists in the index (gomobile compatible)
// Returns filepath if found, empty string if not found
func (idx *ISRCIndex) Lookup(isrc string) (string, error) { func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
path, _ := idx.lookup(isrc) path, _ := idx.lookup(isrc)
return path, nil return path, nil
} }
// Add adds a new ISRC to the index (call after successful download)
func (idx *ISRCIndex) Add(isrc, filePath string) { func (idx *ISRCIndex) Add(isrc, filePath string) {
if isrc == "" || filePath == "" { if isrc == "" || filePath == "" {
return return
@@ -144,15 +136,12 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
idx.index[strings.ToUpper(isrc)] = filePath idx.index[strings.ToUpper(isrc)] = filePath
} }
// InvalidateCache clears the ISRC index cache for a directory
func InvalidateISRCCache(outputDir string) { func InvalidateISRCCache(outputDir string) {
isrcIndexCacheMu.Lock() isrcIndexCacheMu.Lock()
delete(isrcIndexCache, outputDir) delete(isrcIndexCache, outputDir)
isrcIndexCacheMu.Unlock() isrcIndexCacheMu.Unlock()
} }
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
// Uses ISRC index for fast lookup
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) { func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
if isrc == "" || outputDir == "" { if isrc == "" || outputDir == "" {
return "", false return "", false
@@ -173,13 +162,11 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
return filePath, true return filePath, true
} }
// CheckISRCExists is the exported version for gomobile (returns string, error)
func CheckISRCExists(outputDir, isrc string) (string, error) { func CheckISRCExists(outputDir, isrc string) (string, error) {
filepath, _ := checkISRCExistsInternal(outputDir, isrc) filepath, _ := checkISRCExistsInternal(outputDir, isrc)
return filepath, nil return filepath, nil
} }
// CheckFileExists checks if a file with the given name exists
func CheckFileExists(filePath string) bool { func CheckFileExists(filePath string) bool {
info, err := os.Stat(filePath) info, err := os.Stat(filePath)
if err != nil { if err != nil {
@@ -188,7 +175,6 @@ func CheckFileExists(filePath string) bool {
return !info.IsDir() && info.Size() > 0 return !info.IsDir() && info.Size() > 0
} }
// FileExistenceResult represents the result of checking if a file exists
type FileExistenceResult struct { type FileExistenceResult struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Exists bool `json:"exists"` Exists bool `json:"exists"`
@@ -249,8 +235,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
return string(resultJSON), nil return string(resultJSON), nil
} }
// PreBuildISRCIndex pre-builds the ISRC index for a directory
// Call this when app starts or when entering album/playlist screen
func PreBuildISRCIndex(outputDir string) error { func PreBuildISRCIndex(outputDir string) error {
if outputDir == "" { if outputDir == "" {
return fmt.Errorf("output directory is required") return fmt.Errorf("output directory is required")
@@ -260,7 +244,6 @@ func PreBuildISRCIndex(outputDir string) error {
return nil return nil
} }
// AddToISRCIndex adds a new file to the ISRC index after successful download
func AddToISRCIndex(outputDir, isrc, filePath string) { func AddToISRCIndex(outputDir, isrc, filePath string) {
if outputDir == "" || isrc == "" || filePath == "" { if outputDir == "" || isrc == "" || filePath == "" {
return return
+27 -71
View File
@@ -148,17 +148,16 @@ type DownloadRequest struct {
LyricsMode string `json:"lyrics_mode,omitempty"` LyricsMode string `json:"lyrics_mode,omitempty"`
} }
// DownloadResponse represents the result of a download
type DownloadResponse struct { type DownloadResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message"` Message string `json:"message"`
FilePath string `json:"file_path,omitempty"` FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown" ErrorType string `json:"error_type,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"` AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"` ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"` ActualSampleRate int `json:"actual_sample_rate,omitempty"`
Service string `json:"service,omitempty"` // Actual service used (for fallback) Service string `json:"service,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"` Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"` Album string `json:"album,omitempty"`
@@ -172,6 +171,7 @@ type DownloadResponse struct {
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"` Copyright string `json:"copyright,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
} }
type DownloadResult struct { type DownloadResult struct {
@@ -185,6 +185,7 @@ type DownloadResult struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
LyricsLRC string
} }
func DownloadTrack(requestJSON string) (string, error) { func DownloadTrack(requestJSON string) (string, error) {
@@ -222,6 +223,7 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: tidalResult.TrackNumber, TrackNumber: tidalResult.TrackNumber,
DiscNumber: tidalResult.DiscNumber, DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC, ISRC: tidalResult.ISRC,
LyricsLRC: tidalResult.LyricsLRC,
} }
} }
err = tidalErr err = tidalErr
@@ -317,6 +319,7 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: result.TrackNumber, TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber, DiscNumber: result.DiscNumber,
ISRC: result.ISRC, ISRC: result.ISRC,
LyricsLRC: result.LyricsLRC,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
@@ -380,6 +383,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: tidalResult.TrackNumber, TrackNumber: tidalResult.TrackNumber,
DiscNumber: tidalResult.DiscNumber, DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC, ISRC: tidalResult.ISRC,
LyricsLRC: tidalResult.LyricsLRC,
} }
} else if !errors.Is(tidalErr, ErrDownloadCancelled) { } else if !errors.Is(tidalErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr) GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
@@ -452,6 +456,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: result.TrackNumber, TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber, DiscNumber: result.DiscNumber,
ISRC: result.ISRC, ISRC: result.ISRC,
LyricsLRC: result.LyricsLRC,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
@@ -480,6 +485,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: result.TrackNumber, TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber, DiscNumber: result.DiscNumber,
ISRC: result.ISRC, ISRC: result.ISRC,
LyricsLRC: result.LyricsLRC,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
@@ -615,10 +621,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
} }
result := map[string]interface{}{ result := map[string]interface{}{
"success": true, "success": true,
"source": lyrics.Source, "source": lyrics.Source,
"sync_type": lyrics.SyncType, "sync_type": lyrics.SyncType,
"lines": lyrics.Lines, "lines": lyrics.Lines,
"instrumental": lyrics.Instrumental,
} }
jsonBytes, err := json.Marshal(result) jsonBytes, err := json.Marshal(result)
@@ -635,6 +642,7 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
if err == nil && lyrics != "" { if err == nil && lyrics != "" {
return lyrics, nil return lyrics, nil
} }
return "", nil
} }
client := NewLyricsClient() client := NewLyricsClient()
@@ -644,6 +652,10 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
return "", err return "", err
} }
if lyricsData.Instrumental {
return "[instrumental:true]", nil
}
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName) lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
return lrcContent, nil return lrcContent, nil
} }
@@ -706,12 +718,12 @@ func ClearTrackIDCache() {
ClearTrackCache() ClearTrackCache()
} }
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) { func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
client := GetDeezerClient() client := GetDeezerClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) results, err := client.SearchAll(ctx, query, trackLimit, artistLimit, filter)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -725,8 +737,6 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error)
} }
// GetDeezerMetadata fetches metadata from Deezer URL or ID // GetDeezerMetadata fetches metadata from Deezer URL or ID
// resourceType: track, album, artist, playlist
// resourceID: Deezer ID
func GetDeezerMetadata(resourceType, resourceID string) (string, error) { func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@@ -760,7 +770,6 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ParseDeezerURLExport parses a Deezer URL and returns type and ID
func ParseDeezerURLExport(url string) (string, error) { func ParseDeezerURLExport(url string) (string, error) {
resourceType, resourceID, err := parseDeezerURL(url) resourceType, resourceID, err := parseDeezerURL(url)
if err != nil { if err != nil {
@@ -780,9 +789,6 @@ func ParseDeezerURLExport(url string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetDeezerExtendedMetadata fetches genre and label from Deezer album
// trackID: Deezer track ID (will look up album ID from track)
// Returns JSON with genre, label fields
func GetDeezerExtendedMetadata(trackID string) (string, error) { func GetDeezerExtendedMetadata(trackID string) (string, error) {
if trackID == "" { if trackID == "" {
return "", fmt.Errorf("empty track ID") return "", fmt.Errorf("empty track ID")
@@ -811,7 +817,6 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SearchDeezerByISRC searches for a track by ISRC on Deezer
func SearchDeezerByISRC(isrc string) (string, error) { func SearchDeezerByISRC(isrc string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
@@ -939,9 +944,6 @@ func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
} }
// CheckAvailabilityByPlatformID checks track availability using any platform as source // CheckAvailabilityByPlatformID checks track availability using any platform as source
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube"
// entityType: "song" or "album"
// entityID: the ID on that platform
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) { func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
client := NewSongLinkClient() client := NewSongLinkClient()
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID) availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
@@ -957,19 +959,16 @@ func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (strin
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetSpotifyIDFromDeezerTrack converts a Deezer track ID to Spotify track ID
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) { func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient() client := NewSongLinkClient()
return client.GetSpotifyIDFromDeezer(deezerTrackID) return client.GetSpotifyIDFromDeezer(deezerTrackID)
} }
// GetTidalURLFromDeezerTrack converts a Deezer track ID to Tidal URL
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) { func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient() client := NewSongLinkClient()
return client.GetTidalURLFromDeezer(deezerTrackID) return client.GetTidalURLFromDeezer(deezerTrackID)
} }
// GetAmazonURLFromDeezerTrack converts a Deezer track ID to Amazon Music URL
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) { func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient() client := NewSongLinkClient()
return client.GetAmazonURLFromDeezer(deezerTrackID) return client.GetAmazonURLFromDeezer(deezerTrackID)
@@ -1019,7 +1018,6 @@ func errorResponse(msg string) (string, error) {
// ==================== EXTENSION SYSTEM ==================== // ==================== EXTENSION SYSTEM ====================
// InitExtensionSystem initializes the extension system with directories
func InitExtensionSystem(extensionsDir, dataDir string) error { func InitExtensionSystem(extensionsDir, dataDir string) error {
manager := GetExtensionManager() manager := GetExtensionManager()
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil { if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
@@ -1034,7 +1032,6 @@ func InitExtensionSystem(extensionsDir, dataDir string) error {
return nil return nil
} }
// LoadExtensionsFromDir loads all extensions from a directory
func LoadExtensionsFromDir(dirPath string) (string, error) { func LoadExtensionsFromDir(dirPath string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
loaded, errors := manager.LoadExtensionsFromDirectory(dirPath) loaded, errors := manager.LoadExtensionsFromDirectory(dirPath)
@@ -1056,7 +1053,6 @@ func LoadExtensionsFromDir(dirPath string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// LoadExtensionFromPath loads a single extension from a .spotiflac-ext file
func LoadExtensionFromPath(filePath string) (string, error) { func LoadExtensionFromPath(filePath string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
ext, err := manager.LoadExtensionFromFile(filePath) ext, err := manager.LoadExtensionFromFile(filePath)
@@ -1086,19 +1082,16 @@ func LoadExtensionFromPath(filePath string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// UnloadExtensionByID unloads an extension
func UnloadExtensionByID(extensionID string) error { func UnloadExtensionByID(extensionID string) error {
manager := GetExtensionManager() manager := GetExtensionManager()
return manager.UnloadExtension(extensionID) return manager.UnloadExtension(extensionID)
} }
// RemoveExtensionByID completely removes an extension (unload + delete files)
func RemoveExtensionByID(extensionID string) error { func RemoveExtensionByID(extensionID string) error {
manager := GetExtensionManager() manager := GetExtensionManager()
return manager.RemoveExtension(extensionID) return manager.RemoveExtension(extensionID)
} }
// UpgradeExtensionFromPath upgrades an existing extension from a new package file
func UpgradeExtensionFromPath(filePath string) (string, error) { func UpgradeExtensionFromPath(filePath string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
ext, err := manager.UpgradeExtension(filePath) ext, err := manager.UpgradeExtension(filePath)
@@ -1127,25 +1120,21 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// CheckExtensionUpgradeFromPath checks if a package file is an upgrade for an existing extension
func CheckExtensionUpgradeFromPath(filePath string) (string, error) { func CheckExtensionUpgradeFromPath(filePath string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
return manager.CheckExtensionUpgradeJSON(filePath) return manager.CheckExtensionUpgradeJSON(filePath)
} }
// GetInstalledExtensions returns all installed extensions as JSON
func GetInstalledExtensions() (string, error) { func GetInstalledExtensions() (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
return manager.GetInstalledExtensionsJSON() return manager.GetInstalledExtensionsJSON()
} }
// SetExtensionEnabledByID enables or disables an extension
func SetExtensionEnabledByID(extensionID string, enabled bool) error { func SetExtensionEnabledByID(extensionID string, enabled bool) error {
manager := GetExtensionManager() manager := GetExtensionManager()
return manager.SetExtensionEnabled(extensionID, enabled) return manager.SetExtensionEnabled(extensionID, enabled)
} }
// SetProviderPriorityJSON sets the provider priority order from JSON array
func SetProviderPriorityJSON(priorityJSON string) error { func SetProviderPriorityJSON(priorityJSON string) error {
var priority []string var priority []string
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
@@ -1156,7 +1145,6 @@ func SetProviderPriorityJSON(priorityJSON string) error {
return nil return nil
} }
// GetProviderPriorityJSON returns the provider priority order as JSON
func GetProviderPriorityJSON() (string, error) { func GetProviderPriorityJSON() (string, error) {
priority := GetProviderPriority() priority := GetProviderPriority()
jsonBytes, err := json.Marshal(priority) jsonBytes, err := json.Marshal(priority)
@@ -1166,7 +1154,6 @@ func GetProviderPriorityJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SetMetadataProviderPriorityJSON sets the metadata provider priority order from JSON array
func SetMetadataProviderPriorityJSON(priorityJSON string) error { func SetMetadataProviderPriorityJSON(priorityJSON string) error {
var priority []string var priority []string
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
@@ -1177,7 +1164,6 @@ func SetMetadataProviderPriorityJSON(priorityJSON string) error {
return nil return nil
} }
// GetMetadataProviderPriorityJSON returns the metadata provider priority order as JSON
func GetMetadataProviderPriorityJSON() (string, error) { func GetMetadataProviderPriorityJSON() (string, error) {
priority := GetMetadataProviderPriority() priority := GetMetadataProviderPriority()
jsonBytes, err := json.Marshal(priority) jsonBytes, err := json.Marshal(priority)
@@ -1187,7 +1173,6 @@ func GetMetadataProviderPriorityJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetExtensionSettingsJSON returns settings for an extension as JSON
func GetExtensionSettingsJSON(extensionID string) (string, error) { func GetExtensionSettingsJSON(extensionID string) (string, error) {
store := GetExtensionSettingsStore() store := GetExtensionSettingsStore()
settings := store.GetAll(extensionID) settings := store.GetAll(extensionID)
@@ -1200,7 +1185,6 @@ func GetExtensionSettingsJSON(extensionID string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SetExtensionSettingsJSON sets settings for an extension from JSON
func SetExtensionSettingsJSON(extensionID, settingsJSON string) error { func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
var settings map[string]interface{} var settings map[string]interface{}
if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil { if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
@@ -1216,7 +1200,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
return manager.InitializeExtension(extensionID, settings) return manager.InitializeExtension(extensionID, settings)
} }
// SearchTracksWithExtensionsJSON searches all extension metadata providers
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) { func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
tracks, err := manager.SearchTracksWithExtensions(query, limit) tracks, err := manager.SearchTracksWithExtensions(query, limit)
@@ -1232,7 +1215,6 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// DownloadWithExtensionsJSON downloads using extension providers with fallback
func DownloadWithExtensionsJSON(requestJSON string) (string, error) { func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
var req DownloadRequest var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
@@ -1252,14 +1234,11 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// CleanupExtensions unloads all extensions gracefully
func CleanupExtensions() { func CleanupExtensions() {
manager := GetExtensionManager() manager := GetExtensionManager()
manager.UnloadAllExtensions() manager.UnloadAllExtensions()
} }
// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler)
// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.)
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) { func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
result, err := manager.InvokeAction(extensionID, actionName) result, err := manager.InvokeAction(extensionID, actionName)
@@ -1275,7 +1254,6 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetExtensionPendingAuthJSON returns pending auth request for an extension
func GetExtensionPendingAuthJSON(extensionID string) (string, error) { func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
req := GetPendingAuthRequest(extensionID) req := GetPendingAuthRequest(extensionID)
if req == nil { if req == nil {
@@ -1296,12 +1274,10 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SetExtensionAuthCodeByID sets auth code for an extension (called from Flutter after OAuth callback)
func SetExtensionAuthCodeByID(extensionID, authCode string) { func SetExtensionAuthCodeByID(extensionID, authCode string) {
SetExtensionAuthCode(extensionID, authCode) SetExtensionAuthCode(extensionID, authCode)
} }
// SetExtensionTokensByID sets tokens for an extension
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) { func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
var expiresAt time.Time var expiresAt time.Time
if expiresIn > 0 { if expiresIn > 0 {
@@ -1310,12 +1286,10 @@ func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expir
SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt) SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt)
} }
// ClearExtensionPendingAuthByID clears pending auth request for an extension
func ClearExtensionPendingAuthByID(extensionID string) { func ClearExtensionPendingAuthByID(extensionID string) {
ClearPendingAuthRequest(extensionID) ClearPendingAuthRequest(extensionID)
} }
// IsExtensionAuthenticatedByID checks if an extension is authenticated
func IsExtensionAuthenticatedByID(extensionID string) bool { func IsExtensionAuthenticatedByID(extensionID string) bool {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -1332,7 +1306,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool {
return state.IsAuthenticated return state.IsAuthenticated
} }
// GetAllPendingAuthRequestsJSON returns all pending auth requests
func GetAllPendingAuthRequestsJSON() (string, error) { func GetAllPendingAuthRequestsJSON() (string, error) {
pendingAuthRequestsMu.RLock() pendingAuthRequestsMu.RLock()
defer pendingAuthRequestsMu.RUnlock() defer pendingAuthRequestsMu.RUnlock()
@@ -1376,12 +1349,10 @@ func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SetFFmpegCommandResultByID sets the result of an FFmpeg command
func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) { func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) {
SetFFmpegCommandResult(commandID, success, output, errorMsg) SetFFmpegCommandResult(commandID, success, output, errorMsg)
} }
// GetAllPendingFFmpegCommandsJSON returns all pending FFmpeg commands
func GetAllPendingFFmpegCommandsJSON() (string, error) { func GetAllPendingFFmpegCommandsJSON() (string, error) {
ffmpegCommandsMu.RLock() ffmpegCommandsMu.RLock()
defer ffmpegCommandsMu.RUnlock() defer ffmpegCommandsMu.RUnlock()
@@ -1407,8 +1378,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
// ==================== EXTENSION CUSTOM SEARCH ==================== // ==================== EXTENSION CUSTOM SEARCH ====================
// EnrichTrackWithExtensionJSON enriches track metadata using the source extension
// This is called lazily before download starts, allowing extension to fetch real ISRC etc.
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) { func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID) ext, err := manager.GetExtension(extensionID)
@@ -1439,7 +1408,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// CustomSearchWithExtensionJSON performs custom search using an extension
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) { func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID) ext, err := manager.GetExtension(extensionID)
@@ -1492,7 +1460,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetSearchProvidersJSON returns all extensions that provide custom search
func GetSearchProvidersJSON() (string, error) { func GetSearchProvidersJSON() (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
providers := manager.GetSearchProviders() providers := manager.GetSearchProviders()
@@ -1656,8 +1623,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// FindURLHandlerJSON finds an extension that can handle the given URL
// Returns extension ID or empty string if none found
func FindURLHandlerJSON(url string) string { func FindURLHandlerJSON(url string) string {
manager := GetExtensionManager() manager := GetExtensionManager()
handler := manager.FindURLHandler(url) handler := manager.FindURLHandler(url)
@@ -1667,7 +1632,6 @@ func FindURLHandlerJSON(url string) string {
return handler.extension.ID return handler.extension.ID
} }
// GetAlbumWithExtensionJSON gets album tracks using an extension
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) { func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID) ext, err := manager.GetExtension(extensionID)
@@ -1698,6 +1662,11 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
if trackCover == "" { if trackCover == "" {
trackCover = album.CoverURL trackCover = album.CoverURL
} }
// Use track number from extension, fallback to index+1 if not provided
trackNum := track.TrackNumber
if trackNum == 0 {
trackNum = i + 1
}
tracks[i] = map[string]interface{}{ tracks[i] = map[string]interface{}{
"id": track.ID, "id": track.ID,
"name": track.Name, "name": track.Name,
@@ -1707,7 +1676,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
"duration_ms": track.DurationMS, "duration_ms": track.DurationMS,
"cover_url": trackCover, "cover_url": trackCover,
"release_date": track.ReleaseDate, "release_date": track.ReleaseDate,
"track_number": track.TrackNumber, "track_number": trackNum,
"disc_number": track.DiscNumber, "disc_number": track.DiscNumber,
"isrc": track.ISRC, "isrc": track.ISRC,
"provider_id": track.ProviderID, "provider_id": track.ProviderID,
@@ -1737,7 +1706,6 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetPlaylistWithExtensionJSON gets playlist tracks using an extension
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) { func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID) ext, err := manager.GetExtension(extensionID)
@@ -1829,7 +1797,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetArtistWithExtensionJSON gets artist info and albums using an extension
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) { func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID) ext, err := manager.GetExtension(extensionID)
@@ -1913,7 +1880,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetURLHandlersJSON returns all extensions that handle custom URLs
func GetURLHandlersJSON() (string, error) { func GetURLHandlersJSON() (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
handlers := manager.GetURLHandlers() handlers := manager.GetURLHandlers()
@@ -1957,7 +1923,6 @@ func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetPostProcessingProvidersJSON returns all extensions that provide post-processing
func GetPostProcessingProvidersJSON() (string, error) { func GetPostProcessingProvidersJSON() (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
providers := manager.GetPostProcessingProviders() providers := manager.GetPostProcessingProviders()
@@ -1990,13 +1955,11 @@ func GetPostProcessingProvidersJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// InitExtensionStoreJSON initializes the extension store with cache directory
func InitExtensionStoreJSON(cacheDir string) error { func InitExtensionStoreJSON(cacheDir string) error {
InitExtensionStore(cacheDir) InitExtensionStore(cacheDir)
return nil return nil
} }
// GetStoreExtensionsJSON returns all extensions from the store with installation status
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
store := GetExtensionStore() store := GetExtensionStore()
if store == nil { if store == nil {
@@ -2020,7 +1983,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SearchStoreExtensionsJSON searches extensions in the store
func SearchStoreExtensionsJSON(query, category string) (string, error) { func SearchStoreExtensionsJSON(query, category string) (string, error) {
store := GetExtensionStore() store := GetExtensionStore()
if store == nil { if store == nil {
@@ -2040,7 +2002,6 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetStoreCategoriesJSON returns all available categories
func GetStoreCategoriesJSON() (string, error) { func GetStoreCategoriesJSON() (string, error) {
store := GetExtensionStore() store := GetExtensionStore()
if store == nil { if store == nil {
@@ -2056,8 +2017,6 @@ func GetStoreCategoriesJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// DownloadStoreExtensionJSON downloads an extension from the store
// Returns the path to the downloaded file
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
store := GetExtensionStore() store := GetExtensionStore()
if store == nil { if store == nil {
@@ -2073,7 +2032,6 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
return destPath, nil return destPath, nil
} }
// ClearStoreCacheJSON clears the store cache
func ClearStoreCacheJSON() error { func ClearStoreCacheJSON() error {
store := GetExtensionStore() store := GetExtensionStore()
if store == nil { if store == nil {
@@ -2124,12 +2082,10 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
func GetExtensionHomeFeedJSON(extensionID string) (string, error) { func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second) return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
} }
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) { func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second) return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
} }
-13
View File
@@ -55,7 +55,6 @@ type LoadedExtension struct {
IconPath string `json:"icon_path"` IconPath string `json:"icon_path"`
} }
// ExtensionManager manages all loaded extensions
type ExtensionManager struct { type ExtensionManager struct {
mu sync.RWMutex mu sync.RWMutex
extensions map[string]*LoadedExtension extensions map[string]*LoadedExtension
@@ -283,7 +282,6 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
return nil return nil
} }
// UnloadExtension unloads an extension by ID
func (m *ExtensionManager) UnloadExtension(extensionID string) error { func (m *ExtensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -311,7 +309,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return nil return nil
} }
// Returns error if extension not found (gomobile compatible)
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) { func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -323,7 +320,6 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
return ext, nil return ext, nil
} }
// GetAllExtensions returns all loaded extensions
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension { func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -356,7 +352,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return nil return nil
} }
// LoadExtensionsFromDirectory scans a directory and loads all valid extensions
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) { func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
var loaded []string var loaded []string
var errors []error var errors []error
@@ -456,7 +451,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return ext, nil return ext, nil
} }
// RemoveExtension completely removes an extension (unload + delete files)
func (m *ExtensionManager) RemoveExtension(extensionID string) error { func (m *ExtensionManager) RemoveExtension(extensionID string) error {
ext, err := m.GetExtension(extensionID) ext, err := m.GetExtension(extensionID)
if err != nil { if err != nil {
@@ -637,8 +631,6 @@ type ExtensionUpgradeInfo struct {
IsInstalled bool `json:"is_installed"` IsInstalled bool `json:"is_installed"`
} }
// checkExtensionUpgradeInternal checks if a package file is an upgrade for an existing extension
// Internal function that returns struct
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) { func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
// Validate file extension // Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
@@ -714,7 +706,6 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetInstalledExtensionsJSON returns all extensions as JSON for Flutter
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions() extensions := m.GetAllExtensions()
@@ -809,8 +800,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== Extension Lifecycle ====================
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error { func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -923,7 +912,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
return nil return nil
} }
// UnloadAllExtensions unloads all extensions gracefully
func (m *ExtensionManager) UnloadAllExtensions() { func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Lock() m.mu.Lock()
extensionIDs := make([]string, 0, len(m.extensions)) extensionIDs := make([]string, 0, len(m.extensions))
@@ -940,7 +928,6 @@ func (m *ExtensionManager) UnloadAllExtensions() {
GoLog("[Extension] All extensions unloaded\n") GoLog("[Extension] All extensions unloaded\n")
} }
// The function is called as extension.<actionName>() and can return a result
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) { func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
+45 -63
View File
@@ -7,7 +7,6 @@ import (
"strings" "strings"
) )
// ExtensionType represents the type of extension
type ExtensionType string type ExtensionType string
const ( const (
@@ -15,7 +14,6 @@ const (
ExtensionTypeDownloadProvider ExtensionType = "download_provider" ExtensionTypeDownloadProvider ExtensionType = "download_provider"
) )
// SettingType represents the type of a setting field
type SettingType string type SettingType string
const ( const (
@@ -26,14 +24,12 @@ const (
SettingTypeButton SettingType = "button" // Action button that calls a JS function SettingTypeButton SettingType = "button" // Action button that calls a JS function
) )
// ExtensionPermissions defines what resources an extension can access
type ExtensionPermissions struct { type ExtensionPermissions struct {
Network []string `json:"network"` // List of allowed domains Network []string `json:"network"`
Storage bool `json:"storage"` // Whether extension can use storage API Storage bool `json:"storage"`
File bool `json:"file"` // Whether extension can use file API File bool `json:"file"`
} }
// ExtensionSetting defines a configurable setting for an extension
type ExtensionSetting struct { type ExtensionSetting struct {
Key string `json:"key"` Key string `json:"key"`
Type SettingType `json:"type"` Type SettingType `json:"type"`
@@ -42,19 +38,17 @@ type ExtensionSetting struct {
Required bool `json:"required,omitempty"` Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"` Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"` Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type Options []string `json:"options,omitempty"`
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin") Action string `json:"action,omitempty"`
} }
// QualityOption represents a quality option for download providers
type QualityOption struct { type QualityOption struct {
ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128") ID string `json:"id"`
Label string `json:"label"` // Display name (e.g., "MP3 320kbps") Label string `json:"label"`
Description string `json:"description"` // Optional description (e.g., "Best quality MP3") Description string `json:"description"`
Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings Settings []QualitySpecificSetting `json:"settings,omitempty"`
} }
// QualitySpecificSetting represents a setting that's specific to a quality option
type QualitySpecificSetting struct { type QualitySpecificSetting struct {
Key string `json:"key"` Key string `json:"key"`
Type SettingType `json:"type"` Type SettingType `json:"type"`
@@ -63,49 +57,50 @@ type QualitySpecificSetting struct {
Required bool `json:"required,omitempty"` Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"` Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"` Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type Options []string `json:"options,omitempty"`
}
type SearchFilter struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Icon string `json:"icon,omitempty"`
} }
// SearchBehaviorConfig defines custom search behavior for an extension
type SearchBehaviorConfig struct { type SearchBehaviorConfig struct {
Enabled bool `json:"enabled"` // Whether extension provides custom search Enabled bool `json:"enabled"`
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box Placeholder string `json:"placeholder,omitempty"`
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab Primary bool `json:"primary,omitempty"`
Icon string `json:"icon,omitempty"` // Icon for search tab Icon string `json:"icon,omitempty"`
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3) ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
Filters []SearchFilter `json:"filters,omitempty"`
} }
// URLHandlerConfig defines custom URL handling for an extension
type URLHandlerConfig struct { type URLHandlerConfig struct {
Enabled bool `json:"enabled"` // Whether extension handles URLs Enabled bool `json:"enabled"`
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com") Patterns []string `json:"patterns,omitempty"`
} }
// TrackMatchingConfig defines custom track matching behavior
type TrackMatchingConfig struct { type TrackMatchingConfig struct {
CustomMatching bool `json:"customMatching"` // Whether extension handles matching CustomMatching bool `json:"customMatching"`
Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom" Strategy string `json:"strategy,omitempty"`
DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching DurationTolerance int `json:"durationTolerance,omitempty"`
} }
// PostProcessingHook defines a post-processing hook
type PostProcessingHook struct { type PostProcessingHook struct {
ID string `json:"id"` // Unique identifier ID string `json:"id"`
Name string `json:"name"` // Display name Name string `json:"name"`
Description string `json:"description,omitempty"` // Description Description string `json:"description,omitempty"`
DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default DefaultEnabled bool `json:"defaultEnabled,omitempty"`
SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"]) SupportedFormats []string `json:"supportedFormats,omitempty"`
} }
// PostProcessingConfig defines post-processing capabilities
type PostProcessingConfig struct { type PostProcessingConfig struct {
Enabled bool `json:"enabled"` // Whether extension provides post-processing Enabled bool `json:"enabled"`
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks Hooks []PostProcessingHook `json:"hooks,omitempty"`
} }
// ExtensionManifest represents the manifest.json of an extension
type ExtensionManifest struct { type ExtensionManifest struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
@@ -113,22 +108,21 @@ type ExtensionManifest struct {
Author string `json:"author"` Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png") Icon string `json:"icon,omitempty"`
Types []ExtensionType `json:"type"` Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"` Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"` Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"` MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon) SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.) Capabilities map[string]interface{} `json:"capabilities,omitempty"`
} }
// ManifestValidationError represents a validation error in the manifest
type ManifestValidationError struct { type ManifestValidationError struct {
Field string Field string
Message string Message string
@@ -138,7 +132,6 @@ func (e *ManifestValidationError) Error() string {
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message) return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
} }
// ParseManifest parses and validates a manifest from JSON bytes
func ParseManifest(data []byte) (*ExtensionManifest, error) { func ParseManifest(data []byte) (*ExtensionManifest, error) {
var manifest ExtensionManifest var manifest ExtensionManifest
if err := json.Unmarshal(data, &manifest); err != nil { if err := json.Unmarshal(data, &manifest); err != nil {
@@ -217,7 +210,6 @@ func (m *ExtensionManifest) Validate() error {
return nil return nil
} }
// HasType checks if the extension has a specific type
func (m *ExtensionManifest) HasType(t ExtensionType) bool { func (m *ExtensionManifest) HasType(t ExtensionType) bool {
for _, et := range m.Types { for _, et := range m.Types {
if et == t { if et == t {
@@ -227,17 +219,14 @@ func (m *ExtensionManifest) HasType(t ExtensionType) bool {
return false return false
} }
// IsMetadataProvider returns true if extension provides metadata
func (m *ExtensionManifest) IsMetadataProvider() bool { func (m *ExtensionManifest) IsMetadataProvider() bool {
return m.HasType(ExtensionTypeMetadataProvider) return m.HasType(ExtensionTypeMetadataProvider)
} }
// IsDownloadProvider returns true if extension provides downloads
func (m *ExtensionManifest) IsDownloadProvider() bool { func (m *ExtensionManifest) IsDownloadProvider() bool {
return m.HasType(ExtensionTypeDownloadProvider) return m.HasType(ExtensionTypeDownloadProvider)
} }
// IsDomainAllowed checks if a domain is in the allowed network permissions
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool { func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain)) domain = strings.ToLower(strings.TrimSpace(domain))
for _, allowed := range m.Permissions.Network { for _, allowed := range m.Permissions.Network {
@@ -256,27 +245,22 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
return false return false
} }
// HasCustomSearch returns true if extension provides custom search
func (m *ExtensionManifest) HasCustomSearch() bool { func (m *ExtensionManifest) HasCustomSearch() bool {
return m.SearchBehavior != nil && m.SearchBehavior.Enabled return m.SearchBehavior != nil && m.SearchBehavior.Enabled
} }
// HasCustomMatching returns true if extension provides custom track matching
func (m *ExtensionManifest) HasCustomMatching() bool { func (m *ExtensionManifest) HasCustomMatching() bool {
return m.TrackMatching != nil && m.TrackMatching.CustomMatching return m.TrackMatching != nil && m.TrackMatching.CustomMatching
} }
// HasPostProcessing returns true if extension provides post-processing
func (m *ExtensionManifest) HasPostProcessing() bool { func (m *ExtensionManifest) HasPostProcessing() bool {
return m.PostProcessing != nil && m.PostProcessing.Enabled return m.PostProcessing != nil && m.PostProcessing.Enabled
} }
// HasURLHandler returns true if extension handles custom URLs
func (m *ExtensionManifest) HasURLHandler() bool { func (m *ExtensionManifest) HasURLHandler() bool {
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0 return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
} }
// MatchesURL checks if a URL matches any of the extension's URL patterns
func (m *ExtensionManifest) MatchesURL(urlStr string) bool { func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
if !m.HasURLHandler() { if !m.HasURLHandler() {
return false return false
@@ -293,7 +277,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
return false return false
} }
// GetPostProcessingHooks returns all post-processing hooks
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook { func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
if m.PostProcessing == nil { if m.PostProcessing == nil {
return nil return nil
@@ -301,7 +284,6 @@ func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
return m.PostProcessing.Hooks return m.PostProcessing.Hooks
} }
// ToJSON serializes the manifest to JSON
func (m *ExtensionManifest) ToJSON() ([]byte, error) { func (m *ExtensionManifest) ToJSON() ([]byte, error) {
return json.Marshal(m) return json.Marshal(m)
} }
+16 -1
View File
@@ -732,7 +732,7 @@ func GetMetadataProviderPriority() []string {
// isBuiltInProvider checks if a provider ID is a built-in provider // isBuiltInProvider checks if a provider ID is a built-in provider
func isBuiltInProvider(providerID string) bool { func isBuiltInProvider(providerID string) bool {
switch providerID { switch providerID {
case "tidal", "qobuz", "amazon": case "tidal", "qobuz", "amazon", "deezer":
return true return true
default: default:
return false return false
@@ -748,6 +748,21 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
priority := GetProviderPriority() priority := GetProviderPriority()
extManager := GetExtensionManager() 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 {
newPriority = append(newPriority, p)
}
}
priority = newPriority
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
}
var lastErr error var lastErr error
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
+3 -14
View File
@@ -23,9 +23,8 @@ type ExtensionAuthState struct {
RefreshToken string RefreshToken string
ExpiresAt time.Time ExpiresAt time.Time
IsAuthenticated bool IsAuthenticated bool
// PKCE support PKCEVerifier string
PKCEVerifier string PKCEChallenge string
PKCEChallenge string
} }
type PendingAuthRequest struct { type PendingAuthRequest struct {
@@ -39,7 +38,6 @@ var (
pendingAuthRequestsMu sync.RWMutex pendingAuthRequestsMu sync.RWMutex
) )
// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter)
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest { func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
pendingAuthRequestsMu.RLock() pendingAuthRequestsMu.RLock()
defer pendingAuthRequestsMu.RUnlock() defer pendingAuthRequestsMu.RUnlock()
@@ -201,7 +199,6 @@ func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings r.settings = settings
} }
// RegisterAPIs registers all sandboxed APIs to the Goja VM
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
r.vm = vm r.vm = vm
@@ -212,7 +209,7 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
httpObj.Set("put", r.httpPut) httpObj.Set("put", r.httpPut)
httpObj.Set("delete", r.httpDelete) httpObj.Set("delete", r.httpDelete)
httpObj.Set("patch", r.httpPatch) httpObj.Set("patch", r.httpPatch)
httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.) httpObj.Set("request", r.httpRequest)
httpObj.Set("clearCookies", r.httpClearCookies) httpObj.Set("clearCookies", r.httpClearCookies)
vm.Set("http", httpObj) vm.Set("http", httpObj)
@@ -222,7 +219,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
storageObj.Set("remove", r.storageRemove) storageObj.Set("remove", r.storageRemove)
vm.Set("storage", storageObj) vm.Set("storage", storageObj)
// Secure Credentials API (encrypted storage for sensitive data)
credentialsObj := vm.NewObject() credentialsObj := vm.NewObject()
credentialsObj.Set("store", r.credentialsStore) credentialsObj.Set("store", r.credentialsStore)
credentialsObj.Set("get", r.credentialsGet) credentialsObj.Set("get", r.credentialsGet)
@@ -237,7 +233,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
authObj.Set("clearAuth", r.authClear) authObj.Set("clearAuth", r.authClear)
authObj.Set("isAuthenticated", r.authIsAuthenticated) authObj.Set("isAuthenticated", r.authIsAuthenticated)
authObj.Set("getTokens", r.authGetTokens) authObj.Set("getTokens", r.authGetTokens)
// PKCE support
authObj.Set("generatePKCE", r.authGeneratePKCE) authObj.Set("generatePKCE", r.authGeneratePKCE)
authObj.Set("getPKCE", r.authGetPKCE) authObj.Set("getPKCE", r.authGetPKCE)
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE) authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
@@ -279,14 +274,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("hmacSHA1", r.hmacSHA1) utilsObj.Set("hmacSHA1", r.hmacSHA1)
utilsObj.Set("parseJSON", r.parseJSON) utilsObj.Set("parseJSON", r.parseJSON)
utilsObj.Set("stringifyJSON", r.stringifyJSON) utilsObj.Set("stringifyJSON", r.stringifyJSON)
// Crypto utilities for developers
utilsObj.Set("encrypt", r.cryptoEncrypt) utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt) utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("generateKey", r.cryptoGenerateKey) utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent) utilsObj.Set("randomUserAgent", r.randomUserAgent)
vm.Set("utils", utilsObj) vm.Set("utils", utilsObj)
// Log object (already set in extension_manager.go, but we can enhance it)
logObj := vm.NewObject() logObj := vm.NewObject()
logObj.Set("debug", r.logDebug) logObj.Set("debug", r.logDebug)
logObj.Set("info", r.logInfo) logObj.Set("info", r.logInfo)
@@ -298,10 +291,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper) gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
vm.Set("gobackend", gobackendObj) vm.Set("gobackend", gobackendObj)
// ==================== Browser-like Polyfills ====================
// These make porting browser/Node.js libraries easier
// Global fetch() - Promise-style HTTP API (browser-compatible)
vm.Set("fetch", r.fetchPolyfill) vm.Set("fetch", r.fetchPolyfill)
vm.Set("atob", r.atobPolyfill) vm.Set("atob", r.atobPolyfill)
-2
View File
@@ -70,13 +70,11 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(state.AuthCode) return r.vm.ToValue(state.AuthCode)
} }
// authSetCode sets auth code and tokens (can be called by extension after token exchange)
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
// Can accept either just auth code or an object with tokens
arg := call.Arguments[0].Export() arg := call.Arguments[0].Export()
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
-4
View File
@@ -15,7 +15,6 @@ import (
// ==================== File API (Sandboxed) ==================== // ==================== File API (Sandboxed) ====================
// List of allowed directories for file operations (set by Go backend for download operations)
var ( var (
allowedDownloadDirs []string allowedDownloadDirs []string
allowedDownloadDirsMu sync.RWMutex allowedDownloadDirsMu sync.RWMutex
@@ -49,9 +48,6 @@ func isPathInAllowedDirs(absPath string) bool {
return false return false
} }
// validatePath checks if the path is within the extension's sandbox
// Security: Absolute paths are BLOCKED unless they're in allowed download directories
// Extensions should use relative paths for their own data storage
func (r *ExtensionRuntime) validatePath(path string) (string, error) { func (r *ExtensionRuntime) validatePath(path string) (string, error) {
// Check if extension has file permission // Check if extension has file permission
if !r.manifest.Permissions.File { if !r.manifest.Permissions.File {
+9 -44
View File
@@ -14,14 +14,12 @@ import (
// ==================== HTTP API (Sandboxed) ==================== // ==================== HTTP API (Sandboxed) ====================
// HTTPResponse represents the response from an HTTP request
type HTTPResponse struct { type HTTPResponse struct {
StatusCode int `json:"statusCode"` StatusCode int `json:"statusCode"`
Body string `json:"body"` Body string `json:"body"`
Headers map[string]string `json:"headers"` Headers map[string]string `json:"headers"`
} }
// validateDomain checks if the domain is allowed by the extension's permissions
func (r *ExtensionRuntime) validateDomain(urlStr string) error { func (r *ExtensionRuntime) validateDomain(urlStr string) error {
parsed, err := url.Parse(urlStr) parsed, err := url.Parse(urlStr)
if err != nil { if err != nil {
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
return nil return nil
} }
// httpGet performs a GET request (sandboxed)
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -76,16 +73,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}) })
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
// Only set default User-Agent if not provided by extension
if req.Header.Get("User-Agent") == "" { if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -101,26 +96,24 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}) })
} }
// Extract response headers - return all values as arrays for multi-value headers (cookies, etc.)
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
respHeaders[k] = v[0] respHeaders[k] = v[0]
} else { } else {
respHeaders[k] = v // Return as array if multiple values respHeaders[k] = v
} }
} }
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
}) })
} }
// httpPost performs a POST request (sandboxed)
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -137,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}) })
} }
// Get body if provided - support both string and object
var bodyStr string var bodyStr string
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyArg := call.Arguments[1].Export() bodyArg := call.Arguments[1].Export()
@@ -145,7 +137,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
case string: case string:
bodyStr = v bodyStr = v
case map[string]interface{}, []interface{}: case map[string]interface{}, []interface{}:
// Auto-stringify objects and arrays to JSON
jsonBytes, err := json.Marshal(v) jsonBytes, err := json.Marshal(v)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -154,12 +145,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
} }
bodyStr = string(jsonBytes) bodyStr = string(jsonBytes)
default: default:
// Fallback to string conversion
bodyStr = call.Arguments[1].String() bodyStr = call.Arguments[1].String()
} }
} }
// Get headers if provided
headers := make(map[string]string) headers := make(map[string]string)
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
headersObj := call.Arguments[2].Export() headersObj := call.Arguments[2].Export()
@@ -177,11 +166,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}) })
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
// Only set defaults if not provided by extension
if req.Header.Get("User-Agent") == "" { if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
} }
@@ -189,7 +177,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -205,19 +192,18 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}) })
} }
// Extract response headers - return all values as arrays for multi-value headers
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
respHeaders[k] = v[0] respHeaders[k] = v[0]
} else { } else {
respHeaders[k] = v // Return as array if multiple values respHeaders[k] = v
} }
} }
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
@@ -240,27 +226,22 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}) })
} }
// Default options
method := "GET" method := "GET"
var bodyStr string var bodyStr string
headers := make(map[string]string) headers := make(map[string]string)
// Parse options if provided
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
optionsObj := call.Arguments[1].Export() optionsObj := call.Arguments[1].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok { if opts, ok := optionsObj.(map[string]interface{}); ok {
// Get method
if m, ok := opts["method"].(string); ok { if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m) method = strings.ToUpper(m)
} }
// Get body - support both string and object
if bodyArg, ok := opts["body"]; ok && bodyArg != nil { if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
switch v := bodyArg.(type) { switch v := bodyArg.(type) {
case string: case string:
bodyStr = v bodyStr = v
case map[string]interface{}, []interface{}: case map[string]interface{}, []interface{}:
// Auto-stringify objects and arrays to JSON
jsonBytes, err := json.Marshal(v) jsonBytes, err := json.Marshal(v)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -273,7 +254,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
} }
} }
// Get headers
if h, ok := opts["headers"].(map[string]interface{}); ok { if h, ok := opts["headers"].(map[string]interface{}); ok {
for k, v := range h { for k, v := range h {
headers[k] = fmt.Sprintf("%v", v) headers[k] = fmt.Sprintf("%v", v)
@@ -282,7 +262,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
} }
} }
// Create request
var reqBody io.Reader var reqBody io.Reader
if bodyStr != "" { if bodyStr != "" {
reqBody = strings.NewReader(bodyStr) reqBody = strings.NewReader(bodyStr)
@@ -295,11 +274,10 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}) })
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
// Only set defaults if not provided by extension
if req.Header.Get("User-Agent") == "" { if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
} }
@@ -307,7 +285,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -323,20 +300,18 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}) })
} }
// Extract response headers - return all values as arrays for multi-value headers
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
respHeaders[k] = v[0] respHeaders[k] = v[0]
} else { } else {
respHeaders[k] = v // Return as array if multiple values respHeaders[k] = v
} }
} }
// Return response with helper properties
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
@@ -347,7 +322,6 @@ func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call) return r.httpMethodShortcut("PUT", call)
} }
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call) return r.httpMethodShortcut("DELETE", call)
} }
@@ -356,8 +330,6 @@ func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call) return r.httpMethodShortcut("PATCH", call)
} }
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -377,9 +349,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
var bodyStr string var bodyStr string
headers := make(map[string]string) headers := make(map[string]string)
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
if method == "DELETE" { if method == "DELETE" {
// http.delete(url, headers)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export() headersObj := call.Arguments[1].Export()
if h, ok := headersObj.(map[string]interface{}); ok { if h, ok := headersObj.(map[string]interface{}); ok {
@@ -389,7 +359,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
} }
} }
} else { } else {
// http.put(url, body, headers) / http.patch(url, body, headers)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyArg := call.Arguments[1].Export() bodyArg := call.Arguments[1].Export()
switch v := bodyArg.(type) { switch v := bodyArg.(type) {
@@ -418,7 +387,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
} }
} }
// Create request
var reqBody io.Reader var reqBody io.Reader
if bodyStr != "" { if bodyStr != "" {
reqBody = strings.NewReader(bodyStr) reqBody = strings.NewReader(bodyStr)
@@ -431,7 +399,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}) })
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
@@ -442,7 +409,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -458,7 +424,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}) })
} }
// Extract response headers
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
+1 -29
View File
@@ -17,12 +17,10 @@ import (
// ==================== Storage API ==================== // ==================== Storage API ====================
// getStoragePath returns the path to the extension's storage file
func (r *ExtensionRuntime) getStoragePath() string { func (r *ExtensionRuntime) getStoragePath() string {
return filepath.Join(r.dataDir, "storage.json") return filepath.Join(r.dataDir, "storage.json")
} }
// loadStorage loads the storage data from disk
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) { func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
storagePath := r.getStoragePath() storagePath := r.getStoragePath()
data, err := os.ReadFile(storagePath) data, err := os.ReadFile(storagePath)
@@ -41,7 +39,6 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
return storage, nil return storage, nil
} }
// saveStorage saves the storage data to disk
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error { func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
storagePath := r.getStoragePath() storagePath := r.getStoragePath()
data, err := json.MarshalIndent(storage, "", " ") data, err := json.MarshalIndent(storage, "", " ")
@@ -52,7 +49,6 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
return os.WriteFile(storagePath, data, 0644) return os.WriteFile(storagePath, data, 0644)
} }
// storageGet retrieves a value from storage
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
@@ -68,7 +64,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
value, exists := storage[key] value, exists := storage[key]
if !exists { if !exists {
// Return default value if provided
if len(call.Arguments) > 1 { if len(call.Arguments) > 1 {
return call.Arguments[1] return call.Arguments[1]
} }
@@ -78,7 +73,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value) return r.vm.ToValue(value)
} }
// storageSet stores a value in storage
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -103,7 +97,6 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// storageRemove removes a value from storage
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -127,19 +120,14 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// ==================== Credentials API (Encrypted Storage) ====================
// getCredentialsPath returns the path to the extension's encrypted credentials file
func (r *ExtensionRuntime) getCredentialsPath() string { func (r *ExtensionRuntime) getCredentialsPath() string {
return filepath.Join(r.dataDir, ".credentials.enc") return filepath.Join(r.dataDir, ".credentials.enc")
} }
// getSaltPath returns the path to the extension's encryption salt file
func (r *ExtensionRuntime) getSaltPath() string { func (r *ExtensionRuntime) getSaltPath() string {
return filepath.Join(r.dataDir, ".cred_salt") return filepath.Join(r.dataDir, ".cred_salt")
} }
// getOrCreateSalt gets existing salt or creates a new random one
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) { func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath() saltPath := r.getSaltPath()
@@ -160,22 +148,17 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
return salt, nil return salt, nil
} }
// getEncryptionKey derives an encryption key from extension ID + random salt
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) { func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
// Get or create per-installation random salt
salt, err := r.getOrCreateSalt() salt, err := r.getOrCreateSalt()
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Combine extension ID + random salt for key derivation
// This makes each installation unique, preventing mass decryption attacks
combined := append([]byte(r.extensionID), salt...) combined := append([]byte(r.extensionID), salt...)
hash := sha256.Sum256(combined) hash := sha256.Sum256(combined)
return hash[:], nil return hash[:], nil
} }
// loadCredentials loads and decrypts credentials from disk
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
credPath := r.getCredentialsPath() credPath := r.getCredentialsPath()
data, err := os.ReadFile(credPath) data, err := os.ReadFile(credPath)
@@ -186,7 +169,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
return nil, err return nil, err
} }
// Decrypt the data
key, err := r.getEncryptionKey() key, err := r.getEncryptionKey()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get encryption key: %w", err) return nil, fmt.Errorf("failed to get encryption key: %w", err)
@@ -204,7 +186,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
return creds, nil return creds, nil
} }
// saveCredentials encrypts and saves credentials to disk
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
data, err := json.Marshal(creds) data, err := json.Marshal(creds)
if err != nil { if err != nil {
@@ -221,10 +202,9 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
} }
credPath := r.getCredentialsPath() credPath := r.getCredentialsPath()
return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions return os.WriteFile(credPath, encrypted, 0600)
} }
// credentialsStore stores an encrypted credential
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -260,7 +240,6 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
}) })
} }
// credentialsGet retrieves a decrypted credential
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
@@ -276,7 +255,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
value, exists := creds[key] value, exists := creds[key]
if !exists { if !exists {
// Return default value if provided
if len(call.Arguments) > 1 { if len(call.Arguments) > 1 {
return call.Arguments[1] return call.Arguments[1]
} }
@@ -286,7 +264,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value) return r.vm.ToValue(value)
} }
// credentialsRemove removes a credential
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -310,7 +287,6 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// credentialsHas checks if a credential exists
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -327,9 +303,6 @@ func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(exists) return r.vm.ToValue(exists)
} }
// ==================== Crypto Utilities ====================
// encryptAES encrypts data using AES-GCM
func encryptAES(plaintext []byte, key []byte) ([]byte, error) { func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
@@ -350,7 +323,6 @@ func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
return ciphertext, nil return ciphertext, nil
} }
// decryptAES decrypts data using AES-GCM
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) { func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
+2 -30
View File
@@ -19,7 +19,6 @@ import (
// ==================== Utility Functions ==================== // ==================== Utility Functions ====================
// base64Encode encodes a string to base64
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -28,7 +27,6 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
} }
// base64Decode decodes a base64 string
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -41,7 +39,6 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded)) return r.vm.ToValue(string(decoded))
} }
// md5Hash computes MD5 hash of a string
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -51,7 +48,6 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:])) return r.vm.ToValue(hex.EncodeToString(hash[:]))
} }
// sha256Hash computes SHA256 hash of a string
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -61,7 +57,6 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:])) return r.vm.ToValue(hex.EncodeToString(hash[:]))
} }
// hmacSHA256 computes HMAC-SHA256 of a message with a key
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -74,7 +69,6 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil))) return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
} }
// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -87,9 +81,6 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil))) return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
} }
// hmacSHA1 computes HMAC-SHA1 of a message with a key (for TOTP)
// Arguments: message (string or array of bytes), key (string or array of bytes)
// Returns: array of bytes (for TOTP dynamic truncation)
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue([]byte{}) return r.vm.ToValue([]byte{})
@@ -142,7 +133,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(jsArray) return r.vm.ToValue(jsArray)
} }
// parseJSON parses a JSON string
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
@@ -158,7 +148,6 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result) return r.vm.ToValue(result)
} }
// stringifyJSON converts a value to JSON string
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -174,9 +163,6 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(data)) return r.vm.ToValue(string(data))
} }
// ==================== Crypto Utilities for Extensions ====================
// cryptoEncrypt encrypts a string using AES-GCM (for extension use)
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -188,7 +174,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
plaintext := call.Arguments[0].String() plaintext := call.Arguments[0].String()
keyStr := call.Arguments[1].String() keyStr := call.Arguments[1].String()
// Derive 32-byte key from provided key string
keyHash := sha256.Sum256([]byte(keyStr)) keyHash := sha256.Sum256([]byte(keyStr))
encrypted, err := encryptAES([]byte(plaintext), keyHash[:]) encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
@@ -205,7 +190,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
}) })
} }
// cryptoDecrypt decrypts a string using AES-GCM (for extension use)
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -225,14 +209,13 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
}) })
} }
// Derive 32-byte key from provided key string
keyHash := sha256.Sum256([]byte(keyStr)) keyHash := sha256.Sum256([]byte(keyStr))
decrypted, err := decryptAES(ciphertext, keyHash[:]) decrypted, err := decryptAES(ciphertext, keyHash[:])
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
"error": err.Error(), "error": "invalid base64 ciphertext",
}) })
} }
@@ -242,9 +225,8 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
}) })
} }
// cryptoGenerateKey generates a random encryption key
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
length := 32 // Default 256-bit key length := 32
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok { if l, ok := call.Arguments[0].Export().(float64); ok {
length = int(l) length = int(l)
@@ -266,13 +248,10 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
}) })
} }
// randomUserAgent returns a random Chrome User-Agent string
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent()) return r.vm.ToValue(getRandomUserAgent())
} }
// ==================== Logging Functions ====================
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments) msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg) GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
@@ -305,8 +284,6 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
return strings.Join(parts, " ") return strings.Join(parts, " ")
} }
// ==================== Go Backend Wrappers ====================
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -315,7 +292,6 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
return r.vm.ToValue(sanitizeFilename(input)) return r.vm.ToValue(sanitizeFilename(input))
} }
// RegisterGoBackendAPIs adds more Go backend functions to the VM
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) { func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
gobackendObj := vm.Get("gobackend") gobackendObj := vm.Get("gobackend")
if gobackendObj == nil || goja.IsUndefined(gobackendObj) { if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
@@ -325,7 +301,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
obj := gobackendObj.(*goja.Object) obj := gobackendObj.(*goja.Object)
// Expose sanitizeFilename
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value { obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return vm.ToValue("") return vm.ToValue("")
@@ -333,7 +308,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(sanitizeFilename(call.Arguments[0].String())) return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
}) })
// Expose getAudioQuality
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value { obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return vm.ToValue(map[string]interface{}{ return vm.ToValue(map[string]interface{}{
@@ -356,7 +330,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
}) })
}) })
// Expose buildFilename
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value { obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return vm.ToValue("") return vm.ToValue("")
@@ -373,7 +346,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(buildFilenameFromTemplate(template, metadata)) return vm.ToValue(buildFilenameFromTemplate(template, metadata))
}) })
// Expose getLocalTime - returns device local time info
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value { obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
now := time.Now() now := time.Now()
_, offsetSeconds := now.Zone() _, offsetSeconds := now.Zone()
-15
View File
@@ -9,7 +9,6 @@ import (
"sync" "sync"
) )
// ExtensionSettingsStore manages settings for all extensions
type ExtensionSettingsStore struct { type ExtensionSettingsStore struct {
mu sync.RWMutex mu sync.RWMutex
dataDir string dataDir string
@@ -22,7 +21,6 @@ var (
globalSettingsStoreOnce sync.Once globalSettingsStoreOnce sync.Once
) )
// GetExtensionSettingsStore returns the global settings store
func GetExtensionSettingsStore() *ExtensionSettingsStore { func GetExtensionSettingsStore() *ExtensionSettingsStore {
globalSettingsStoreOnce.Do(func() { globalSettingsStoreOnce.Do(func() {
globalSettingsStore = &ExtensionSettingsStore{ globalSettingsStore = &ExtensionSettingsStore{
@@ -32,7 +30,6 @@ func GetExtensionSettingsStore() *ExtensionSettingsStore {
return globalSettingsStore return globalSettingsStore
} }
// SetDataDir sets the data directory for settings storage
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error { func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -45,12 +42,10 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
return s.loadAllSettings() return s.loadAllSettings()
} }
// getSettingsPath returns the path to an extension's settings file
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string { func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
return filepath.Join(s.dataDir, extensionID, "settings.json") return filepath.Join(s.dataDir, extensionID, "settings.json")
} }
// loadAllSettings loads settings for all extensions from disk
func (s *ExtensionSettingsStore) loadAllSettings() error { func (s *ExtensionSettingsStore) loadAllSettings() error {
entries, err := os.ReadDir(s.dataDir) entries, err := os.ReadDir(s.dataDir)
if err != nil { if err != nil {
@@ -75,7 +70,6 @@ func (s *ExtensionSettingsStore) loadAllSettings() error {
return nil return nil
} }
// loadSettings loads settings for a specific extension
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) { func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
settingsPath := s.getSettingsPath(extensionID) settingsPath := s.getSettingsPath(extensionID)
data, err := os.ReadFile(settingsPath) data, err := os.ReadFile(settingsPath)
@@ -94,7 +88,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
return settings, nil return settings, nil
} }
// saveSettings saves settings for a specific extension
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error { func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
settingsPath := s.getSettingsPath(extensionID) settingsPath := s.getSettingsPath(extensionID)
@@ -111,8 +104,6 @@ func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[s
return os.WriteFile(settingsPath, data, 0644) return os.WriteFile(settingsPath, data, 0644)
} }
// Get retrieves a setting value for an extension
// Returns error if extension or key not found (gomobile compatible)
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) { func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -129,7 +120,6 @@ func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, erro
return value, nil return value, nil
} }
// GetAll retrieves all settings for an extension
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} { func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -147,7 +137,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
return result return result
} }
// Set stores a setting value for an extension
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error { func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -161,7 +150,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
return s.saveSettings(extensionID, s.settings[extensionID]) return s.saveSettings(extensionID, s.settings[extensionID])
} }
// SetAll stores all settings for an extension
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error { func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -172,7 +160,6 @@ func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]
return s.saveSettings(extensionID, settings) return s.saveSettings(extensionID, settings)
} }
// Remove removes a setting for an extension
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error { func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -188,7 +175,6 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
return s.saveSettings(extensionID, extSettings) return s.saveSettings(extensionID, extSettings)
} }
// RemoveAll removes all settings for an extension
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error { func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -203,7 +189,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
return nil return nil
} }
// GetAllExtensionSettings returns settings for all extensions as JSON
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) { func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
+17 -35
View File
@@ -20,28 +20,26 @@ const (
CategoryIntegration = "integration" CategoryIntegration = "integration"
) )
// StoreExtension represents an extension in the store
type StoreExtension struct { type StoreExtension struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"` Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"` IconURL string `json:"icon_url,omitempty"`
Category string `json:"category"` Category string `json:"category"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Downloads int `json:"downloads"` Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"` MinAppVersion string `json:"min_app_version,omitempty"`
DisplayNameAlt string `json:"displayName,omitempty"` DisplayNameAlt string `json:"displayName,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"` DownloadURLAlt string `json:"downloadUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"` IconURLAlt string `json:"iconUrl,omitempty"`
MinAppVersionAlt string `json:"minAppVersion,omitempty"` MinAppVersionAlt string `json:"minAppVersion,omitempty"`
} }
// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict)
func (e *StoreExtension) getDisplayName() string { func (e *StoreExtension) getDisplayName() string {
if e.DisplayName != "" { if e.DisplayName != "" {
return e.DisplayName return e.DisplayName
@@ -52,7 +50,6 @@ func (e *StoreExtension) getDisplayName() string {
return e.Name return e.Name
} }
// getDownloadURL returns download URL from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getDownloadURL() string { func (e *StoreExtension) getDownloadURL() string {
if e.DownloadURL != "" { if e.DownloadURL != "" {
return e.DownloadURL return e.DownloadURL
@@ -60,7 +57,6 @@ func (e *StoreExtension) getDownloadURL() string {
return e.DownloadURLAlt return e.DownloadURLAlt
} }
// getIconURL returns icon URL from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getIconURL() string { func (e *StoreExtension) getIconURL() string {
if e.IconURL != "" { if e.IconURL != "" {
return e.IconURL return e.IconURL
@@ -68,7 +64,6 @@ func (e *StoreExtension) getIconURL() string {
return e.IconURLAlt return e.IconURLAlt
} }
// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getMinAppVersion() string { func (e *StoreExtension) getMinAppVersion() string {
if e.MinAppVersion != "" { if e.MinAppVersion != "" {
return e.MinAppVersion return e.MinAppVersion
@@ -76,7 +71,6 @@ func (e *StoreExtension) getMinAppVersion() string {
return e.MinAppVersionAlt return e.MinAppVersionAlt
} }
// StoreRegistry represents the extension registry
type StoreRegistry struct { type StoreRegistry struct {
Version int `json:"version"` Version int `json:"version"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
@@ -103,7 +97,6 @@ type StoreExtensionResponse struct {
HasUpdate bool `json:"has_update"` HasUpdate bool `json:"has_update"`
} }
// ToResponse converts StoreExtension to normalized response
func (e *StoreExtension) ToResponse() StoreExtensionResponse { func (e *StoreExtension) ToResponse() StoreExtensionResponse {
return StoreExtensionResponse{ return StoreExtensionResponse{
ID: e.ID, ID: e.ID,
@@ -122,7 +115,6 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
} }
} }
// ExtensionStore manages the extension store
type ExtensionStore struct { type ExtensionStore struct {
registryURL string registryURL string
cacheDir string cacheDir string
@@ -143,7 +135,6 @@ const (
cacheFileName = "store_cache.json" cacheFileName = "store_cache.json"
) )
// InitExtensionStore initializes the extension store
func InitExtensionStore(cacheDir string) *ExtensionStore { func InitExtensionStore(cacheDir string) *ExtensionStore {
extensionStoreMu.Lock() extensionStoreMu.Lock()
defer extensionStoreMu.Unlock() defer extensionStoreMu.Unlock()
@@ -160,14 +151,12 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
return extensionStore return extensionStore
} }
// GetExtensionStore returns the singleton store instance
func GetExtensionStore() *ExtensionStore { func GetExtensionStore() *ExtensionStore {
extensionStoreMu.Lock() extensionStoreMu.Lock()
defer extensionStoreMu.Unlock() defer extensionStoreMu.Unlock()
return extensionStore return extensionStore
} }
// loadDiskCache loads cached registry from disk
func (s *ExtensionStore) loadDiskCache() { func (s *ExtensionStore) loadDiskCache() {
if s.cacheDir == "" { if s.cacheDir == "" {
return return
@@ -193,7 +182,6 @@ func (s *ExtensionStore) loadDiskCache() {
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions)) LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
} }
// saveDiskCache saves registry to disk cache
func (s *ExtensionStore) saveDiskCache() { func (s *ExtensionStore) saveDiskCache() {
if s.cacheDir == "" || s.cache == nil { if s.cacheDir == "" || s.cache == nil {
return return
@@ -216,7 +204,6 @@ func (s *ExtensionStore) saveDiskCache() {
os.WriteFile(cachePath, data, 0644) os.WriteFile(cachePath, data, 0644)
} }
// FetchRegistry fetches the extension registry from GitHub
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) { func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
@@ -267,7 +254,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return &registry, nil return &registry, nil
} }
// GetExtensionsWithStatus returns extensions with installation status
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) { func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(false) registry, err := s.FetchRegistry(false)
if err != nil { if err != nil {
@@ -299,7 +285,6 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
return result, nil return result, nil
} }
// DownloadExtension downloads an extension package to the specified path
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error { func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false) registry, err := s.FetchRegistry(false)
if err != nil { if err != nil {
@@ -347,7 +332,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return nil return nil
} }
// GetCategories returns all available categories
func (s *ExtensionStore) GetCategories() []string { func (s *ExtensionStore) GetCategories() []string {
return []string{ return []string{
CategoryMetadata, CategoryMetadata,
@@ -358,7 +342,6 @@ func (s *ExtensionStore) GetCategories() []string {
} }
} }
// SearchExtensions searches extensions by query
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) { func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus() extensions, err := s.GetExtensionsWithStatus()
if err != nil { if err != nil {
@@ -404,7 +387,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
return result, nil return result, nil
} }
// ClearCache clears the in-memory and disk cache
func (s *ExtensionStore) ClearCache() { func (s *ExtensionStore) ClearCache() {
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
+12 -6
View File
@@ -1,23 +1,29 @@
module github.com/zarz/spotiflac_android/go_backend module github.com/zarz/spotiflac_android/go_backend
go 1.24.0 go 1.25.0
toolchain go1.24.5 toolchain go1.25.6
require ( require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0 github.com/go-flac/go-flac v1.0.0
github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4
golang.org/x/net v0.49.0
) )
require ( require (
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect github.com/klauspost/compress v1.17.4 // indirect
golang.org/x/mod v0.31.0 // indirect golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/tools v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
) )
+22 -8
View File
@@ -1,5 +1,7 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 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/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
@@ -12,17 +14,29 @@ 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/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= 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/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
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/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=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+1 -26
View File
@@ -15,9 +15,6 @@ import (
"time" "time"
) )
// getRandomUserAgent generates a random Windows Chrome User-Agent string
// Uses modern Chrome format with build and patch numbers
// Windows 11 still reports as "Windows NT 10.0" for compatibility
func getRandomUserAgent() string { func getRandomUserAgent() string {
// Chrome version 120-145 (modern range) // Chrome version 120-145 (modern range)
chromeVersion := rand.Intn(26) + 120 chromeVersion := rand.Intn(26) + 120
@@ -38,9 +35,9 @@ const (
SongLinkTimeout = 30 * time.Second SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3 DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second DefaultRetryDelay = 1 * time.Second
Second = time.Second
) )
// Shared transport with connection pooling to prevent TCP exhaustion
var sharedTransport = &http.Transport{ var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
@@ -84,7 +81,6 @@ func GetDownloadClient() *http.Client {
return downloadClient return downloadClient
} }
// CloseIdleConnections closes idle connections in the shared transport
func CloseIdleConnections() { func CloseIdleConnections() {
sharedTransport.CloseIdleConnections() sharedTransport.CloseIdleConnections()
} }
@@ -116,9 +112,6 @@ func DefaultRetryConfig() RetryConfig {
} }
} }
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
// Handles 429 (Too Many Requests) responses with Retry-After header
// Also detects and logs ISP blocking
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) { func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
var lastErr error var lastErr error
delay := config.InitialDelay delay := config.InitialDelay
@@ -148,12 +141,10 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue continue
} }
// Success
if resp.StatusCode >= 200 && resp.StatusCode < 300 { if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return resp, nil return resp, nil
} }
// Handle rate limiting (429)
if resp.StatusCode == 429 { if resp.StatusCode == 429 {
resp.Body.Close() resp.Body.Close()
retryAfter := getRetryAfterDuration(resp) retryAfter := getRetryAfterDuration(resp)
@@ -193,7 +184,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
} }
} }
// Server errors (5xx) - retry
if resp.StatusCode >= 500 { if resp.StatusCode >= 500 {
resp.Body.Close() resp.Body.Close()
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode) lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
@@ -205,7 +195,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue continue
} }
// Client errors (4xx except 429) - don't retry
return resp, nil return resp, nil
} }
@@ -224,12 +213,10 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
return 60 * time.Second // Default wait time return 60 * time.Second // Default wait time
} }
// Try parsing as seconds
if seconds, err := strconv.Atoi(retryAfter); err == nil { if seconds, err := strconv.Atoi(retryAfter); err == nil {
return time.Duration(seconds) * time.Second return time.Duration(seconds) * time.Second
} }
// Try parsing as HTTP date
if t, err := http.ParseTime(retryAfter); err == nil { if t, err := http.ParseTime(retryAfter); err == nil {
duration := time.Until(t) duration := time.Until(t)
if duration > 0 { if duration > 0 {
@@ -240,8 +227,6 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
return 60 * time.Second // Default return 60 * time.Second // Default
} }
// ReadResponseBody reads and returns the response body
// Returns error if body is empty
func ReadResponseBody(resp *http.Response) ([]byte, error) { func ReadResponseBody(resp *http.Response) ([]byte, error) {
if resp == nil { if resp == nil {
return nil, fmt.Errorf("response is nil") return nil, fmt.Errorf("response is nil")
@@ -271,14 +256,12 @@ func ValidateResponse(resp *http.Response) error {
return nil return nil
} }
// BuildErrorMessage creates a detailed error message for API failures
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string { func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
msg := fmt.Sprintf("API %s failed", apiURL) msg := fmt.Sprintf("API %s failed", apiURL)
if statusCode > 0 { if statusCode > 0 {
msg += fmt.Sprintf(" (HTTP %d)", statusCode) msg += fmt.Sprintf(" (HTTP %d)", statusCode)
} }
if responsePreview != "" { if responsePreview != "" {
// Truncate preview if too long
if len(responsePreview) > 100 { if len(responsePreview) > 100 {
responsePreview = responsePreview[:100] + "..." responsePreview = responsePreview[:100] + "..."
} }
@@ -297,18 +280,14 @@ func (e *ISPBlockingError) Error() string {
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason) return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
} }
// IsISPBlocking checks if an error is likely caused by ISP blocking
// Returns the ISPBlockingError if detected, nil otherwise
func IsISPBlocking(err error, requestURL string) *ISPBlockingError { func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil { if err == nil {
return nil return nil
} }
// Extract domain from URL
domain := extractDomain(requestURL) domain := extractDomain(requestURL)
errStr := strings.ToLower(err.Error()) errStr := strings.ToLower(err.Error())
// Check for DNS resolution failure (common ISP blocking method)
var dnsErr *net.DNSError var dnsErr *net.DNSError
if errors.As(err, &dnsErr) { if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound || dnsErr.IsTemporary { if dnsErr.IsNotFound || dnsErr.IsTemporary {
@@ -320,11 +299,9 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
} }
} }
// Check for connection refused (ISP firewall blocking)
var opErr *net.OpError var opErr *net.OpError
if errors.As(err, &opErr) { if errors.As(err, &opErr) {
if opErr.Op == "dial" { if opErr.Op == "dial" {
// Check for specific syscall errors
var syscallErr syscall.Errno var syscallErr syscall.Errno
if errors.As(opErr.Err, &syscallErr) { if errors.As(opErr.Err, &syscallErr) {
switch syscallErr { switch syscallErr {
@@ -363,7 +340,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
} }
} }
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
var tlsErr *tls.RecordHeaderError var tlsErr *tls.RecordHeaderError
if errors.As(err, &tlsErr) { if errors.As(err, &tlsErr) {
return &ISPBlockingError{ return &ISPBlockingError{
@@ -424,7 +400,6 @@ func extractDomain(rawURL string) string {
parsed, err := url.Parse(rawURL) parsed, err := url.Parse(rawURL)
if err != nil { if err != nil {
// Try to extract domain manually
rawURL = strings.TrimPrefix(rawURL, "https://") rawURL = strings.TrimPrefix(rawURL, "https://")
rawURL = strings.TrimPrefix(rawURL, "http://") rawURL = strings.TrimPrefix(rawURL, "http://")
if idx := strings.Index(rawURL, "/"); idx > 0 { if idx := strings.Index(rawURL, "/"); idx > 0 {
+27
View File
@@ -0,0 +1,27 @@
//go:build ios
package gobackend
import (
"net/http"
)
// iOS version: uTLS is not supported on iOS due to cgo DNS resolver issues
// Fall back to standard HTTP client
// GetCloudflareBypassClient returns the standard HTTP client on iOS
// uTLS is not available on iOS due to cgo DNS resolver compatibility issues
func GetCloudflareBypassClient() *http.Client {
return sharedClient
}
// DoRequestWithCloudflareBypass on iOS just uses the standard client
// uTLS Chrome fingerprint bypass is not available on iOS
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := sharedClient.Do(req)
if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
}
return resp, err
}
+188
View File
@@ -0,0 +1,188 @@
//go:build !ios
package gobackend
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
utls "github.com/refraction-networking/utls"
"golang.org/x/net/http2"
)
// uTLS transport that mimics Chrome's TLS fingerprint to bypass Cloudflare
// Uses HTTP/2 for optimal performance as uTLS works best with HTTP/2
type utlsTransport struct {
dialer *net.Dialer
mu sync.Mutex
h2Transports map[string]*http2.Transport
}
func newUTLSTransport() *utlsTransport {
return &utlsTransport{
dialer: &net.Dialer{
Timeout: 30 * Second,
KeepAlive: 30 * Second,
},
h2Transports: make(map[string]*http2.Transport),
}
}
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)
}
host := req.URL.Hostname()
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
}, 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
},
AllowHTTP: false,
DisableCompression: false,
}
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
},
DisableKeepAlives: true,
}
return transport.RoundTrip(req)
}
func (t *utlsTransport) getPort(u *url.URL) string {
if u.Port() != "" {
return u.Port()
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
// Cloudflare bypass client using uTLS Chrome fingerprint
var cloudflareBypassTransport = newUTLSTransport()
var cloudflareBypassClient = &http.Client{
Transport: cloudflareBypassTransport,
Timeout: DefaultTimeout,
}
// GetCloudflareBypassClient returns an HTTP client that mimics Chrome's TLS fingerprint
// Use this when requests are blocked by Cloudflare (common when using VPN)
func GetCloudflareBypassClient() *http.Client {
return cloudflareBypassClient
}
// DoRequestWithCloudflareBypass attempts request with standard client first,
// then retries with uTLS Chrome fingerprint if Cloudflare blocks it.
// This is useful when using VPN as Cloudflare detects Go's default TLS fingerprint.
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
// Try with standard client first
resp, err := sharedClient.Do(req)
if err == nil {
// Check for Cloudflare challenge page (403 with specific markers)
if resp.StatusCode == 403 || resp.StatusCode == 503 {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr == nil {
bodyStr := strings.ToLower(string(body))
cloudflareMarkers := []string{
"cloudflare", "cf-ray", "checking your browser",
"please wait", "ddos protection", "ray id",
"enable javascript", "challenge-platform",
}
isCloudflare := false
for _, marker := range cloudflareMarkers {
if strings.Contains(bodyStr, marker) {
isCloudflare = true
break
}
}
if isCloudflare {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
// Clone request for retry
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
// Retry with uTLS Chrome fingerprint
return cloudflareBypassClient.Do(reqCopy)
}
}
// Not Cloudflare, return original response (recreate body)
return &http.Response{
Status: resp.Status,
StatusCode: resp.StatusCode,
Header: resp.Header,
Body: io.NopCloser(strings.NewReader(string(body))),
}, nil
}
return resp, nil
}
// Check if error might be TLS-related (Cloudflare blocking)
errStr := strings.ToLower(err.Error())
tlsRelated := strings.Contains(errStr, "tls") ||
strings.Contains(errStr, "handshake") ||
strings.Contains(errStr, "certificate") ||
strings.Contains(errStr, "connection reset")
if tlsRelated {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
// Clone request for retry
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
// Retry with uTLS Chrome fingerprint
return cloudflareBypassClient.Do(reqCopy)
}
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
return nil, err
}
+189
View File
@@ -0,0 +1,189 @@
package gobackend
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// IDHSClient is a client for I Don't Have Spotify API
// Used as fallback when SongLink fails or is rate limited
type IDHSClient struct {
client *http.Client
}
var (
globalIDHSClient *IDHSClient
idhsClientOnce sync.Once
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
)
// IDHSSearchRequest represents the request body for IDHS API
type IDHSSearchRequest struct {
Link string `json:"link"`
Adapters []string `json:"adapters,omitempty"`
}
// IDHSSearchResponse represents the response from IDHS API
type IDHSSearchResponse struct {
ID string `json:"id"`
Type string `json:"type"` // song, album, artist, podcast, show
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image,omitempty"`
Audio string `json:"audio,omitempty"`
Source string `json:"source"`
UniversalLink string `json:"universalLink"`
Links []IDHSLink `json:"links"`
}
// IDHSLink represents a link to a streaming platform
type IDHSLink struct {
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
URL string `json:"url"`
IsVerified bool `json:"isVerified,omitempty"`
NotAvailable bool `json:"notAvailable,omitempty"`
}
// NewIDHSClient creates a new IDHS client
func NewIDHSClient() *IDHSClient {
idhsClientOnce.Do(func() {
globalIDHSClient = &IDHSClient{
client: NewHTTPClientWithTimeout(15 * time.Second),
}
})
return globalIDHSClient
}
// Search converts a music link to links on other platforms
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
idhsRateLimiter.WaitForSlot()
reqBody := IDHSSearchRequest{
Link: link,
Adapters: adapters,
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", "https://idonthavespotify.sjdonado.com/api/search?v=1", bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 400 {
return nil, fmt.Errorf("invalid link or missing parameters")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("IDHS rate limit exceeded")
}
if resp.StatusCode == 500 {
return nil, fmt.Errorf("IDHS processing failed")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("IDHS API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result IDHSSearchResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
// Request only the platforms we need
adapters := []string{"tidal", "deezer"}
result, err := c.Search(spotifyURL, adapters)
if err != nil {
return nil, err
}
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
}
for _, link := range result.Links {
if link.NotAvailable {
continue
}
switch strings.ToLower(link.Type) {
case "tidal":
availability.Tidal = true
availability.TidalURL = link.URL
case "deezer":
availability.Deezer = true
availability.DeezerURL = link.URL
availability.DeezerID = extractDeezerIDFromURL(link.URL)
}
}
LogDebug("IDHS", "Availability from Spotify %s: Tidal=%v, Deezer=%v",
spotifyTrackID, availability.Tidal, availability.Deezer)
return availability, nil
}
// GetAvailabilityFromDeezer checks track availability using IDHS
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
// Request only the platforms we need
adapters := []string{"spotify", "tidal"}
result, err := c.Search(deezerURL, adapters)
if err != nil {
return nil, err
}
availability := &TrackAvailability{
Deezer: true,
DeezerID: deezerTrackID,
}
for _, link := range result.Links {
if link.NotAvailable {
continue
}
switch strings.ToLower(link.Type) {
case "spotify":
availability.SpotifyID = extractSpotifyIDFromURL(link.URL)
case "tidal":
availability.Tidal = true
availability.TidalURL = link.URL
}
}
LogDebug("IDHS", "Availability from Deezer %s: Spotify=%s, Tidal=%v",
deezerTrackID, availability.SpotifyID, availability.Tidal)
return availability, nil
}
+2 -2
View File
@@ -150,11 +150,11 @@ func GoLog(format string, args ...interface{}) {
// Determine level from message content // Determine level from message content
msgLower := strings.ToLower(message) msgLower := strings.ToLower(message)
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") { if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
level = "ERROR" level = "ERROR"
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") { } else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
level = "WARN" level = "WARN"
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") { } else if strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
level = "INFO" level = "INFO"
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") { } else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
level = "DEBUG" level = "DEBUG"
+37 -40
View File
@@ -238,9 +238,9 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
return diff <= durationToleranceSec return diff <= durationToleranceSec
} }
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) { func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
// Check cache first primaryArtist := normalizeArtistName(artistName)
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found { if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName) fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached cachedCopy := *cached
@@ -251,39 +251,48 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
var lyrics *LyricsResponse var lyrics *LyricsResponse
var err error var err error
// Try exact match first isValidResult := func(l *LyricsResponse) bool {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName) return l != nil && (len(l.Lines) > 0 || l.Instrumental)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { }
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB" lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
} }
// Try with simplified track name if primaryArtist != artistName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
simplifiedTrack := simplifyTrackName(trackName) simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName { if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack) lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB (simplified)" lyrics.Source = "LRCLIB (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
} }
} }
// Search with duration matching query := primaryArtist + " " + trackName
query := artistName + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search" lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
} }
// Search with simplified name and duration matching
if simplifiedTrack != trackName { if simplifiedTrack != trackName {
query = artistName + " " + simplifiedTrack query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search (simplified)" lyrics.Source = "LRCLIB Search (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
@@ -375,32 +384,6 @@ func msToLRCTimestamp(ms int64) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
} }
// Use convertToLRCWithMetadata for full LRC with headers
// Kept for potential future use
// func convertToLRC(lyrics *LyricsResponse) string {
// if lyrics == nil || len(lyrics.Lines) == 0 {
// return ""
// }
//
// var builder strings.Builder
//
// if lyrics.SyncType == "LINE_SYNCED" {
// for _, line := range lyrics.Lines {
// timestamp := msToLRCTimestamp(line.StartTimeMs)
// builder.WriteString(timestamp)
// builder.WriteString(line.Words)
// builder.WriteString("\n")
// }
// } else {
// for _, line := range lyrics.Lines {
// builder.WriteString(line.Words)
// builder.WriteString("\n")
// }
// }
//
// return builder.String()
// }
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string { func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
if lyrics == nil || len(lyrics.Lines) == 0 { if lyrics == nil || len(lyrics.Lines) == 0 {
return "" return ""
@@ -462,6 +445,20 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result) return strings.TrimSpace(result)
} }
func normalizeArtistName(name string) string {
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
result := name
for _, sep := range separators {
if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 {
result = result[:idx]
break
}
}
return strings.TrimSpace(result)
}
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) { func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
if lrcContent == "" { if lrcContent == "" {
return "", fmt.Errorf("empty LRC content") return "", fmt.Errorf("empty LRC content")
+33 -413
View File
@@ -238,7 +238,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
return f.Save(filePath) return f.Save(filePath)
} }
// ReadMetadata reads metadata from a FLAC file
func ReadMetadata(filePath string) (*Metadata, error) { func ReadMetadata(filePath string) (*Metadata, error) {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -336,6 +335,39 @@ func fileExists(path string) bool {
return err == nil return err == nil
} }
func ExtractCoverArt(filePath string) ([]byte, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, meta := range f.Meta {
if meta.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
if pic.PictureType == flacpicture.PictureTypeFrontCover && len(pic.ImageData) > 0 {
return pic.ImageData, nil
}
}
}
for _, meta := range f.Meta {
if meta.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
if len(pic.ImageData) > 0 {
return pic.ImageData, nil
}
}
}
return nil, fmt.Errorf("no cover art found in file")
}
func EmbedLyrics(filePath string, lyrics string) error { func EmbedLyrics(filePath string, lyrics string) error {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -418,7 +450,6 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
return f.Save(filePath) return f.Save(filePath)
} }
// ExtractLyrics extracts embedded lyrics from a FLAC file
func ExtractLyrics(filePath string) (string, error) { func ExtractLyrics(filePath string) (string, error) {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -512,371 +543,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)") return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
} }
// ========================================
// M4A (MP4/AAC) Metadata Embedding
// ========================================
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
input, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open M4A file: %w", err)
}
defer input.Close()
info, err := input.Stat()
if err != nil {
return fmt.Errorf("failed to stat M4A file: %w", err)
}
fileSize := info.Size()
moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize)
if err != nil {
return fmt.Errorf("failed to find moov atom: %w", err)
}
if !moovFound {
return fmt.Errorf("moov atom not found in M4A file")
}
moovContentStart := moovHeader.offset + moovHeader.headerSize
moovContentSize := moovHeader.size - moovHeader.headerSize
udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize)
if err != nil {
return fmt.Errorf("failed to locate udta atom: %w", err)
}
var metaHeader atomHeader
metaFound := false
if udtaFound {
udtaContentStart := udtaHeader.offset + udtaHeader.headerSize
udtaContentSize := udtaHeader.size - udtaHeader.headerSize
metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize)
if err != nil {
return fmt.Errorf("failed to locate meta atom: %w", err)
}
}
metaAtom := buildMetaAtom(metadata, coverData)
metaSize := int64(len(metaAtom))
var delta int64
var newUdtaSize int64
switch {
case udtaFound && metaFound:
delta = metaSize - metaHeader.size
newUdtaSize = udtaHeader.size + delta
case udtaFound && !metaFound:
delta = metaSize
newUdtaSize = udtaHeader.size + delta
case !udtaFound:
newUdtaSize = int64(8 + len(metaAtom))
delta = newUdtaSize
}
newMoovSize := moovHeader.size + delta
if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) {
return fmt.Errorf("moov atom exceeds 32-bit size after update")
}
if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) {
return fmt.Errorf("udta atom exceeds 32-bit size after update")
}
if !udtaFound && newUdtaSize > int64(^uint32(0)) {
return fmt.Errorf("udta atom exceeds 32-bit size after update")
}
tempPath := filePath + ".tmp"
output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
cleanupTemp := true
defer func() {
_ = output.Close()
if cleanupTemp {
_ = os.Remove(tempPath)
}
}()
switch {
case udtaFound && metaFound:
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(metaAtom); err != nil {
return fmt.Errorf("failed to write meta atom: %w", err)
}
metaEnd := metaHeader.offset + metaHeader.size
if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil {
return err
}
case udtaFound && !metaFound:
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
return err
}
insertPos := udtaHeader.offset + udtaHeader.size
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(metaAtom); err != nil {
return fmt.Errorf("failed to write meta atom: %w", err)
}
if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil {
return err
}
case !udtaFound:
newUdtaAtom := buildUdtaAtom(metaAtom)
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
moovEnd := moovHeader.offset + moovHeader.size
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(newUdtaAtom); err != nil {
return fmt.Errorf("failed to write udta atom: %w", err)
}
if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil {
return err
}
}
if err := output.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
_ = input.Close()
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
if err := os.Rename(tempPath, filePath); err != nil {
return fmt.Errorf("failed to move temp file: %w", err)
}
cleanupTemp = false
fmt.Printf("[M4A] Metadata embedded successfully\n")
return nil
}
func findAtom(data []byte, name string, offset int) int {
for i := offset; i < len(data)-8; {
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
if size < 8 {
break
}
atomName := string(data[i+4 : i+8])
if atomName == name {
return i
}
i += size
}
return -1
}
// buildMetaAtom builds a complete meta atom with ilst containing metadata
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
var ilst []byte
if metadata.Title != "" {
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
}
if metadata.Artist != "" {
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
}
if metadata.Album != "" {
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
}
if metadata.AlbumArtist != "" {
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
}
if metadata.Date != "" {
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
}
if metadata.TrackNumber > 0 {
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
}
if metadata.DiscNumber > 0 {
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
}
if metadata.Lyrics != "" {
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
}
if len(coverData) > 0 {
ilst = append(ilst, buildCoverAtom(coverData)...)
}
ilstSize := 8 + len(ilst)
ilstAtom := make([]byte, 4)
ilstAtom[0] = byte(ilstSize >> 24)
ilstAtom[1] = byte(ilstSize >> 16)
ilstAtom[2] = byte(ilstSize >> 8)
ilstAtom[3] = byte(ilstSize)
ilstAtom = append(ilstAtom, []byte("ilst")...)
ilstAtom = append(ilstAtom, ilst...)
hdlr := []byte{
0, 0, 0, 33, // size = 33
'h', 'd', 'l', 'r',
0, 0, 0, 0, // version + flags
0, 0, 0, 0, // predefined
'm', 'd', 'i', 'r', // handler type
'a', 'p', 'p', 'l', // manufacturer
0, 0, 0, 0, // component flags
0, 0, 0, 0, // component flags mask
0, // null terminator
}
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
metaContent = append(metaContent, ilstAtom...)
metaSize := 8 + len(metaContent)
metaAtom := make([]byte, 4)
metaAtom[0] = byte(metaSize >> 24)
metaAtom[1] = byte(metaSize >> 16)
metaAtom[2] = byte(metaSize >> 8)
metaAtom[3] = byte(metaSize)
metaAtom = append(metaAtom, []byte("meta")...)
metaAtom = append(metaAtom, metaContent...)
return metaAtom
}
func buildTextAtom(name, value string) []byte {
valueBytes := []byte(value)
dataSize := 16 + len(valueBytes)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
dataAtom[1] = byte(dataSize >> 16)
dataAtom[2] = byte(dataSize >> 8)
dataAtom[3] = byte(dataSize)
dataAtom = append(dataAtom, []byte("data")...)
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, valueBytes...)
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte(name)...)
atom = append(atom, dataAtom...)
return atom
}
// buildTrackNumberAtom builds trkn atom
func buildTrackNumberAtom(track, total int) []byte {
dataAtom := []byte{
0, 0, 0, 24, // size
'd', 'a', 't', 'a',
0, 0, 0, 0, // type = implicit
0, 0, 0, 0, // locale
0, 0, // padding
byte(track >> 8), byte(track), // track number
byte(total >> 8), byte(total), // total tracks
0, 0, // padding
}
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte("trkn")...)
atom = append(atom, dataAtom...)
return atom
}
func buildDiscNumberAtom(disc, total int) []byte {
dataAtom := []byte{
0, 0, 0, 22, // size
'd', 'a', 't', 'a',
0, 0, 0, 0, // type = implicit
0, 0, 0, 0, // locale
0, 0, // padding
byte(disc >> 8), byte(disc), // disc number
byte(total >> 8), byte(total), // total discs
}
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte("disk")...)
atom = append(atom, dataAtom...)
return atom
}
// buildCoverAtom builds covr atom with image data
func buildCoverAtom(coverData []byte) []byte {
imageType := byte(13)
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
imageType = 14
}
dataSize := 16 + len(coverData)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
dataAtom[1] = byte(dataSize >> 16)
dataAtom[2] = byte(dataSize >> 8)
dataAtom[3] = byte(dataSize)
dataAtom = append(dataAtom, []byte("data")...)
dataAtom = append(dataAtom, 0, 0, 0, imageType)
dataAtom = append(dataAtom, 0, 0, 0, 0)
dataAtom = append(dataAtom, coverData...)
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte("covr")...)
atom = append(atom, dataAtom...)
return atom
}
func GetM4AQuality(filePath string) (AudioQuality, error) { func GetM4AQuality(filePath string) (AudioQuality, error) {
f, err := os.Open(filePath) f, err := os.Open(filePath)
if err != nil { if err != nil {
@@ -989,52 +655,6 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6
return atomHeader{}, false, nil return atomHeader{}, false, nil
} }
func writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error {
if len(typ) != 4 {
return fmt.Errorf("invalid atom type: %s", typ)
}
if headerSize == 16 {
header := make([]byte, 16)
binary.BigEndian.PutUint32(header[0:4], 1)
copy(header[4:8], []byte(typ))
binary.BigEndian.PutUint64(header[8:16], uint64(size))
_, err := w.Write(header)
return err
}
if size > int64(^uint32(0)) {
return fmt.Errorf("atom size exceeds 32-bit for %s", typ)
}
header := make([]byte, 8)
binary.BigEndian.PutUint32(header[0:4], uint32(size))
copy(header[4:8], []byte(typ))
_, err := w.Write(header)
return err
}
func copyRange(dst io.Writer, src *os.File, offset, length int64) error {
if length <= 0 {
return nil
}
if _, err := src.Seek(offset, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek source: %w", err)
}
if _, err := io.CopyN(dst, src, length); err != nil {
return fmt.Errorf("failed to copy data: %w", err)
}
return nil
}
func buildUdtaAtom(metaAtom []byte) []byte {
size := 8 + len(metaAtom)
header := make([]byte, 8)
binary.BigEndian.PutUint32(header[0:4], uint32(size))
copy(header[4:8], []byte("udta"))
return append(header, metaAtom...)
}
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) { func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
const chunkSize = 64 * 1024 const chunkSize = 64 * 1024
patternMP4A := []byte("mp4a") patternMP4A := []byte("mp4a")
+10
View File
@@ -0,0 +1,10 @@
// mobile_deps.go
// This file ensures gomobile dependencies are not removed by go mod tidy.
// These packages are required by gomobile bind but not directly imported in code.
package gobackend
import (
// Required for gomobile bind to work
_ "golang.org/x/mobile/bind"
)
+55 -28
View File
@@ -14,9 +14,11 @@ type TrackIDCacheEntry struct {
} }
type TrackIDCache struct { type TrackIDCache struct {
cache map[string]*TrackIDCacheEntry cache map[string]*TrackIDCacheEntry
mu sync.RWMutex mu sync.RWMutex
ttl time.Duration ttl time.Duration
lastCleanup time.Time
cleanupInterval time.Duration
} }
var ( var (
@@ -27,8 +29,9 @@ var (
func GetTrackIDCache() *TrackIDCache { func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() { trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{ globalTrackIDCache = &TrackIDCache{
cache: make(map[string]*TrackIDCacheEntry), cache: make(map[string]*TrackIDCacheEntry),
ttl: 30 * time.Minute, ttl: 30 * time.Minute,
cleanupInterval: 5 * time.Minute,
} }
}) })
return globalTrackIDCache return globalTrackIDCache
@@ -36,13 +39,33 @@ func GetTrackIDCache() *TrackIDCache {
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry { func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock()
entry, exists := c.cache[isrc] entry, exists := c.cache[isrc]
if !exists || time.Now().After(entry.ExpiresAt) { if !exists {
c.mu.RUnlock()
return nil return nil
} }
return entry expired := time.Now().After(entry.ExpiresAt)
c.mu.RUnlock()
if !expired {
return entry
}
c.mu.Lock()
entry, exists = c.cache[isrc]
if exists && time.Now().After(entry.ExpiresAt) {
delete(c.cache, isrc)
}
c.mu.Unlock()
return nil
}
func (c *TrackIDCache) pruneExpiredLocked(now time.Time) {
for key, entry := range c.cache {
if now.After(entry.ExpiresAt) {
delete(c.cache, key)
}
}
} }
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) { func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
@@ -55,7 +78,13 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.cache[isrc] = entry c.cache[isrc] = entry
} }
entry.TidalTrackID = trackID entry.TidalTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl) now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
c.pruneExpiredLocked(now)
c.lastCleanup = now
}
} }
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
@@ -68,7 +97,13 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.cache[isrc] = entry c.cache[isrc] = entry
} }
entry.QobuzTrackID = trackID entry.QobuzTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl) now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
c.pruneExpiredLocked(now)
c.lastCleanup = now
}
} }
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) { func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
@@ -81,7 +116,13 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
c.cache[isrc] = entry c.cache[isrc] = entry
} }
entry.AmazonTrackID = trackID entry.AmazonTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl) now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
c.pruneExpiredLocked(now)
c.lastCleanup = now
}
} }
func (c *TrackIDCache) Clear() { func (c *TrackIDCache) Clear() {
@@ -96,7 +137,6 @@ func (c *TrackIDCache) Size() int {
return len(c.cache) return len(c.cache)
} }
// ParallelDownloadResult holds results from parallel operations
type ParallelDownloadResult struct { type ParallelDownloadResult struct {
CoverData []byte CoverData []byte
LyricsData *LyricsResponse LyricsData *LyricsResponse
@@ -121,14 +161,11 @@ func FetchCoverAndLyricsParallel(
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
fmt.Println("[Parallel] Starting cover download...")
data, err := downloadCoverToMemory(coverURL, maxQualityCover) data, err := downloadCoverToMemory(coverURL, maxQualityCover)
if err != nil { if err != nil {
result.CoverErr = err result.CoverErr = err
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
} else { } else {
result.CoverData = data result.CoverData = data
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
} }
}() }()
} }
@@ -137,20 +174,16 @@ func FetchCoverAndLyricsParallel(
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
fmt.Println("[Parallel] Starting lyrics fetch...")
client := NewLyricsClient() client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0 durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil { if err != nil {
result.LyricsErr = err result.LyricsErr = err
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 { } else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = lyrics result.LyricsData = lyrics
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName) result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else { } else {
result.LyricsErr = fmt.Errorf("no lyrics found") result.LyricsErr = fmt.Errorf("no lyrics found")
fmt.Println("[Parallel] No lyrics found")
} }
}() }()
} }
@@ -163,8 +196,8 @@ type PreWarmCacheRequest struct {
ISRC string ISRC string
TrackName string TrackName string
ArtistName string ArtistName string
SpotifyID string // Needed for Amazon (SongLink lookup) SpotifyID string
Service string // "tidal", "qobuz", "amazon" Service string
} }
func PreWarmTrackCache(requests []PreWarmCacheRequest) { func PreWarmTrackCache(requests []PreWarmCacheRequest) {
@@ -172,7 +205,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
return return
} }
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
cache := GetTrackIDCache() cache := GetTrackIDCache()
semaphore := make(chan struct{}, 3) semaphore := make(chan struct{}, 3)
@@ -201,7 +233,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
} }
wg.Wait() wg.Wait()
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
} }
func preWarmTidalCache(isrc, _, _ string) { func preWarmTidalCache(isrc, _, _ string) {
@@ -209,7 +240,6 @@ func preWarmTidalCache(isrc, _, _ string) {
track, err := downloader.SearchTrackByISRC(isrc) track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil { if err == nil && track != nil {
GetTrackIDCache().SetTidal(isrc, track.ID) GetTrackIDCache().SetTidal(isrc, track.ID)
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
} }
} }
@@ -218,7 +248,6 @@ func preWarmQobuzCache(isrc string) {
track, err := downloader.SearchTrackByISRC(isrc) track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil { if err == nil && track != nil {
GetTrackIDCache().SetQobuz(isrc, track.ID) GetTrackIDCache().SetQobuz(isrc, track.ID)
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
} }
} }
@@ -227,7 +256,6 @@ func preWarmAmazonCache(isrc, spotifyID string) {
availability, err := client.CheckTrackAvailability(spotifyID, isrc) availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon { if err == nil && availability != nil && availability.Amazon {
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL) GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
} }
} }
@@ -240,7 +268,6 @@ func PreWarmCache(tracksJSON string) error {
func ClearTrackCache() { func ClearTrackCache() {
GetTrackIDCache().Clear() GetTrackIDCache().Clear()
fmt.Println("[Cache] Track ID cache cleared")
} }
func GetCacheSize() int { func GetCacheSize() int {
-13
View File
@@ -78,7 +78,6 @@ func GetItemProgress(itemID string) string {
return "{}" return "{}"
} }
// StartItemProgress initializes progress tracking for an item
func StartItemProgress(itemID string) { func StartItemProgress(itemID string) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -93,7 +92,6 @@ func StartItemProgress(itemID string) {
} }
} }
// SetItemBytesTotal sets total bytes for an item
func SetItemBytesTotal(itemID string, total int64) { func SetItemBytesTotal(itemID string, total int64) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -103,7 +101,6 @@ func SetItemBytesTotal(itemID string, total int64) {
} }
} }
// SetItemBytesReceived sets bytes received for an item
func SetItemBytesReceived(itemID string, received int64) { func SetItemBytesReceived(itemID string, received int64) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -116,7 +113,6 @@ func SetItemBytesReceived(itemID string, received int64) {
} }
} }
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) { func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -130,7 +126,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
} }
} }
// CompleteItemProgress marks an item as complete
func CompleteItemProgress(itemID string) { func CompleteItemProgress(itemID string) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -142,7 +137,6 @@ func CompleteItemProgress(itemID string) {
} }
} }
// SetItemProgress sets progress for an item directly
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) { func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -158,7 +152,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
} }
} }
// SetItemFinalizing marks an item as finalizing (embedding metadata)
func SetItemFinalizing(itemID string) { func SetItemFinalizing(itemID string) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -169,7 +162,6 @@ func SetItemFinalizing(itemID string) {
} }
} }
// RemoveItemProgress removes progress tracking for an item
func RemoveItemProgress(itemID string) { func RemoveItemProgress(itemID string) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -177,7 +169,6 @@ func RemoveItemProgress(itemID string) {
delete(multiProgress.Items, itemID) delete(multiProgress.Items, itemID)
} }
// ClearAllItemProgress clears all item progress
func ClearAllItemProgress() { func ClearAllItemProgress() {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -185,7 +176,6 @@ func ClearAllItemProgress() {
multiProgress.Items = make(map[string]*ItemProgress) multiProgress.Items = make(map[string]*ItemProgress)
} }
// setDownloadDir sets the default download directory
func setDownloadDir(path string) error { func setDownloadDir(path string) error {
downloadDirMu.Lock() downloadDirMu.Lock()
defer downloadDirMu.Unlock() defer downloadDirMu.Unlock()
@@ -193,7 +183,6 @@ func setDownloadDir(path string) error {
return nil return nil
} }
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
type ItemProgressWriter struct { type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) } writer interface{ Write([]byte) (int, error) }
itemID string itemID string
@@ -206,7 +195,6 @@ type ItemProgressWriter struct {
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
// NewItemProgressWriter creates a new progress writer for a specific item
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter { func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
now := time.Now() now := time.Now()
return &ItemProgressWriter{ return &ItemProgressWriter{
@@ -220,7 +208,6 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
} }
} }
// Write implements io.Writer with threshold-based progress updates and speed tracking
func (pw *ItemProgressWriter) Write(p []byte) (int, error) { func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
if pw.itemID != "" && isDownloadCancelled(pw.itemID) { if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
return 0, ErrDownloadCancelled return 0, ErrDownloadCancelled
+134 -112
View File
@@ -52,12 +52,10 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound { if normExpected == normFound {
return true return true
} }
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true return true
} }
@@ -112,24 +110,19 @@ func qobuzSplitArtists(artists string) []string {
return result return result
} }
// qobuzSameWordsUnordered checks if two strings have the same words regardless of order
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
func qobuzSameWordsUnordered(a, b string) bool { func qobuzSameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a) wordsA := strings.Fields(a)
wordsB := strings.Fields(b) wordsB := strings.Fields(b)
// Must have same number of words
if len(wordsA) != len(wordsB) || len(wordsA) == 0 { if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
return false return false
} }
// Sort and compare
sortedA := make([]string, len(wordsA)) sortedA := make([]string, len(wordsA))
sortedB := make([]string, len(wordsB)) sortedB := make([]string, len(wordsB))
copy(sortedA, wordsA) copy(sortedA, wordsA)
copy(sortedB, wordsB) copy(sortedB, wordsB)
// Simple bubble sort (usually just 2-3 words)
for i := 0; i < len(sortedA)-1; i++ { for i := 0; i < len(sortedA)-1; i++ {
for j := i + 1; j < len(sortedA); j++ { for j := i + 1; j < len(sortedA); j++ {
if sortedA[i] > sortedA[j] { if sortedA[i] > sortedA[j] {
@@ -153,7 +146,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle))
// Exact match
if normExpected == normFound { if normExpected == normFound {
return true return true
} }
@@ -182,8 +174,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := qobuzIsLatinScript(expectedTitle) expectedLatin := qobuzIsLatinScript(expectedTitle)
foundLatin := qobuzIsLatinScript(foundTitle) foundLatin := qobuzIsLatinScript(foundTitle)
if expectedLatin != foundLatin { if expectedLatin != foundLatin {
@@ -194,9 +184,7 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return false return false
} }
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
func qobuzExtractCoreTitle(title string) string { func qobuzExtractCoreTitle(title string) string {
// Find first occurrence of ( or [
parenIdx := strings.Index(title, "(") parenIdx := strings.Index(title, "(")
bracketIdx := strings.Index(title, "[") bracketIdx := strings.Index(title, "[")
dashIdx := strings.Index(title, " - ") dashIdx := strings.Index(title, " - ")
@@ -281,49 +269,28 @@ func qobuzCleanTitle(title string) string {
return strings.TrimSpace(cleaned) return strings.TrimSpace(cleaned)
} }
// qobuzIsLatinScript checks if a string is primarily Latin script
// Returns true for ASCII and Latin Extended characters (European languages)
// Returns false for CJK, Arabic, Cyrillic, etc.
func qobuzIsLatinScript(s string) bool { func qobuzIsLatinScript(s string) bool {
for _, r := range s { for _, r := range s {
// Skip common punctuation and numbers
if r < 128 { if r < 128 {
continue continue
} }
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.) if (r >= 0x0100 && r <= 0x024F) ||
// Latin Extended-B: U+0180 to U+024F (r >= 0x1E00 && r <= 0x1EFF) ||
// Latin Extended Additional: U+1E00 to U+1EFF (r >= 0x00C0 && r <= 0x00FF) {
// Latin Extended-C/D/E: various ranges
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
continue continue
} }
// CJK ranges - definitely different script if (r >= 0x4E00 && r <= 0x9FFF) ||
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs (r >= 0x3040 && r <= 0x309F) ||
(r >= 0x3040 && r <= 0x309F) || // Hiragana (r >= 0x30A0 && r <= 0x30FF) ||
(r >= 0x30A0 && r <= 0x30FF) || // Katakana (r >= 0xAC00 && r <= 0xD7AF) ||
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean) (r >= 0x0600 && r <= 0x06FF) ||
(r >= 0x0600 && r <= 0x06FF) || // Arabic (r >= 0x0400 && r <= 0x04FF) {
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
return false return false
} }
} }
return true return true
} }
// qobuzIsASCIIString checks if a string contains only ASCII characters
// Kept for potential future use
// func qobuzIsASCIIString(s string) bool {
// for _, r := range s {
// if r > 127 {
// return false
// }
// }
// return true
// }
// containsQueryQobuz checks if a query already exists in the list
func containsQueryQobuz(queries []string, query string) bool { func containsQueryQobuz(queries []string, query string) bool {
for _, q := range queries { for _, q := range queries {
if q == query { if q == query {
@@ -336,7 +303,7 @@ func containsQueryQobuz(queries []string, query string) bool {
func NewQobuzDownloader() *QobuzDownloader { func NewQobuzDownloader() *QobuzDownloader {
qobuzDownloaderOnce.Do(func() { qobuzDownloaderOnce.Do(func() {
globalQobuzDownloader = &QobuzDownloader{ globalQobuzDownloader = &QobuzDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout client: NewHTTPClientWithTimeout(DefaultTimeout),
appID: "798273057", appID: "798273057",
} }
}) })
@@ -344,7 +311,6 @@ func NewQobuzDownloader() *QobuzDownloader {
} }
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
// Qobuz API: /track/get?track_id=XXX
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID) trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
@@ -371,14 +337,11 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
return &track, nil return &track, nil
} }
// GetAvailableAPIs returns list of available Qobuz APIs
// Uses same APIs as PC version for compatibility
func (q *QobuzDownloader) GetAvailableAPIs() []string { func (q *QobuzDownloader) GetAvailableAPIs() []string {
// Same APIs as PC version (referensi/backend/qobuz.go)
// Primary: dab.yeet.su, Fallback: dabmusic.xyz
encodedAPIs := []string{ encodedAPIs := []string{
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC) "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==",
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC) "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=",
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=",
} }
var apis []string var apis []string
@@ -393,6 +356,86 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
return apis return apis
} }
func mapJumoQuality(quality string) int {
switch quality {
case "6":
return 6
case "7":
return 7
case "27":
return 27
default:
return 6
}
}
func decodeXOR(data []byte) string {
text := string(data)
runes := []rune(text)
result := make([]rune, len(runes))
for i, char := range runes {
key := rune((i * 17) % 128)
result[i] = char ^ 253 ^ key
}
return string(result)
}
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&region=%s", trackID, formatID, region)
GoLog("[Qobuz] Trying Jumo API fallback...\n")
client := NewHTTPClientWithTimeout(30 * time.Second)
req, err := http.NewRequest("GET", jumoURL, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("Jumo API returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
decoded := decodeXOR(body)
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err)
}
}
if urlVal, ok := result["url"].(string); ok && urlVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully\n")
return urlVal, nil
}
if data, ok := result["data"].(map[string]any); ok {
if urlVal, ok := data["url"].(string); ok && urlVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully (from data)\n")
return urlVal, nil
}
}
if linkVal, ok := result["link"].(string); ok && linkVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully (from link)\n")
return linkVal, nil
}
return "", fmt.Errorf("URL not found in Jumo response")
}
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
@@ -421,7 +464,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, err return nil, err
} }
// Find exact ISRC match
for i := range result.Tracks.Items { for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc { if result.Tracks.Items[i].ISRC == isrc {
return &result.Tracks.Items[i], nil return &result.Tracks.Items[i], nil
@@ -435,7 +477,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
} }
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
@@ -468,7 +509,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items)) GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
// Find ISRC matches
var isrcMatches []*QobuzTrack var isrcMatches []*QobuzTrack
for i := range result.Tracks.Items { for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc { if result.Tracks.Items[i].ISRC == isrc {
@@ -522,35 +562,26 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0) return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
} }
// Now includes romaji conversion for Japanese text (same as Tidal)
// Also includes title verification to prevent wrong song downloads
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
// Try multiple search strategies (same as Tidal/PC version)
queries := []string{} queries := []string{}
// Strategy 1: Artist + Track name
if artistName != "" && trackName != "" { if artistName != "" && trackName != "" {
queries = append(queries, artistName+" "+trackName) queries = append(queries, artistName+" "+trackName)
} }
// Strategy 2: Track name only
if trackName != "" { if trackName != "" {
queries = append(queries, trackName) queries = append(queries, trackName)
} }
// Strategy 3: Romaji versions if Japanese detected
if ContainsJapanese(trackName) || ContainsJapanese(artistName) { if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
// Convert to romaji (hiragana/katakana only, kanji stays)
romajiTrack := JapaneseToRomaji(trackName) romajiTrack := JapaneseToRomaji(trackName)
romajiArtist := JapaneseToRomaji(artistName) romajiArtist := JapaneseToRomaji(artistName)
// Clean and remove ALL non-ASCII characters (including kanji)
cleanRomajiTrack := CleanToASCII(romajiTrack) cleanRomajiTrack := CleanToASCII(romajiTrack)
cleanRomajiArtist := CleanToASCII(romajiArtist) cleanRomajiArtist := CleanToASCII(romajiArtist)
// Artist + Track romaji (cleaned to ASCII only)
if cleanRomajiArtist != "" && cleanRomajiTrack != "" { if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
if !containsQueryQobuz(queries, romajiQuery) { if !containsQueryQobuz(queries, romajiQuery) {
@@ -559,7 +590,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
} }
// Track romaji only (cleaned)
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName { if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
if !containsQueryQobuz(queries, cleanRomajiTrack) { if !containsQueryQobuz(queries, cleanRomajiTrack) {
queries = append(queries, cleanRomajiTrack) queries = append(queries, cleanRomajiTrack)
@@ -567,7 +597,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
} }
// Strategy 4: Artist only as last resort
if artistName != "" { if artistName != "" {
artistOnly := CleanToASCII(JapaneseToRomaji(artistName)) artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) { if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
@@ -626,7 +655,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName) return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
} }
// Filter by title match first (NEW - like Tidal)
var titleMatches []*QobuzTrack var titleMatches []*QobuzTrack
for i := range allTracks { for i := range allTracks {
track := &allTracks[i] track := &allTracks[i]
@@ -637,7 +665,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks)) GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
// If no title matches, log warning but continue with all tracks
tracksToCheck := titleMatches tracksToCheck := titleMatches
if len(titleMatches) == 0 { if len(titleMatches) == 0 {
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks)) GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
@@ -646,7 +673,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
} }
// If duration verification is requested
if expectedDurationSec > 0 { if expectedDurationSec > 0 {
var durationMatches []*QobuzTrack var durationMatches []*QobuzTrack
for _, track := range tracksToCheck { for _, track := range tracksToCheck {
@@ -662,12 +688,12 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
if len(durationMatches) > 0 { if len(durationMatches) > 0 {
for _, track := range durationMatches { for _, track := range durationMatches {
if track.MaximumBitDepth >= 24 { if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified, hi-res)\n", GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
track.Title, track.Performer.Name) track.Title, track.Performer.Name)
return track, nil return track, nil
} }
} }
GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified)\n", GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified)\n",
durationMatches[0].Title, durationMatches[0].Performer.Name) durationMatches[0].Title, durationMatches[0].Performer.Name)
return durationMatches[0], nil return durationMatches[0], nil
} }
@@ -675,17 +701,16 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec) return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
} }
// No duration verification, return best quality from title matches
for _, track := range tracksToCheck { for _, track := range tracksToCheck {
if track.MaximumBitDepth >= 24 { if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n", GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n",
track.Title, track.Performer.Name) track.Title, track.Performer.Name)
return track, nil return track, nil
} }
} }
if len(tracksToCheck) > 0 { if len(tracksToCheck) > 0 {
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified)\n", GoLog("[Qobuz] Match found: '%s' by '%s' (title verified)\n",
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name) tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
return tracksToCheck[0], nil return tracksToCheck[0], nil
} }
@@ -693,7 +718,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
} }
// qobuzAPIResult holds the result from a parallel API request
type qobuzAPIResult struct { type qobuzAPIResult struct {
apiURL string apiURL string
downloadURL string downloadURL string
@@ -711,7 +735,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
resultChan := make(chan qobuzAPIResult, len(apis)) resultChan := make(chan qobuzAPIResult, len(apis))
startTime := time.Now() startTime := time.Now()
// Start all requests in parallel
for _, apiURL := range apis { for _, apiURL := range apis {
go func(api string) { go func(api string) {
reqStart := time.Now() reqStart := time.Now()
@@ -744,13 +767,11 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
return return
} }
// Check if response is HTML (error page)
if len(body) > 0 && body[0] == '<' { if len(body) > 0 && body[0] == '<' {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)} resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
return return
} }
// Check for error in JSON response
var errorResp struct { var errorResp struct {
Error string `json:"error"` Error string `json:"error"`
} }
@@ -776,15 +797,13 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
}(apiURL) }(apiURL)
} }
// Collect results - return first success
var errors []string var errors []string
for i := 0; i < len(apis); i++ { for i := 0; i < len(apis); i++ {
result := <-resultChan result := <-resultChan
if result.err == nil { if result.err == nil {
GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration) GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration)
// Drain remaining results to avoid goroutine leaks
go func(remaining int) { go func(remaining int) {
for j := 0; j < remaining; j++ { for j := 0; j < remaining; j++ {
<-resultChan <-resultChan
@@ -812,18 +831,38 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
} }
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality) _, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
if err != nil { if err == nil {
return "", err return downloadURL, nil
} }
return downloadURL, nil GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n")
jumoURL, jumoErr := q.downloadFromJumo(trackID, quality)
if jumoErr == nil {
return jumoURL, nil
}
if quality == "27" {
GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n")
jumoURL, jumoErr = q.downloadFromJumo(trackID, "7")
if jumoErr == nil {
return jumoURL, nil
}
}
if quality == "27" || quality == "7" {
GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n")
jumoURL, jumoErr = q.downloadFromJumo(trackID, "6")
if jumoErr == nil {
return jumoURL, nil
}
}
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
} }
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background() ctx := context.Background()
// Initialize item progress (required for all downloads)
if itemID != "" { if itemID != "" {
StartItemProgress(itemID) StartItemProgress(itemID)
defer CompleteItemProgress(itemID) defer CompleteItemProgress(itemID)
@@ -873,7 +912,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
written, err = io.Copy(bufWriter, resp.Body) written, err = io.Copy(bufWriter, resp.Body)
} }
// Flush buffer before checking for errors
flushErr := bufWriter.Flush() flushErr := bufWriter.Flush()
closeErr := out.Close() closeErr := out.Close()
@@ -893,7 +931,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return fmt.Errorf("failed to close file: %w", closeErr) return fmt.Errorf("failed to close file: %w", closeErr)
} }
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize { if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath) os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
@@ -941,11 +978,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
} }
} }
// OPTIMIZATION: Check cache first for track ID
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID) GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
// For Qobuz we need to search again to get full track info, but we can use the ID
track, err = downloader.SearchTrackByISRC(req.ISRC) track, err = downloader.SearchTrackByISRC(req.ISRC)
if err != nil { if err != nil {
GoLog("[Qobuz] Cache hit but search failed: %v\n", err) GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
@@ -954,11 +989,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
} }
} }
// Strategy 1: Search by ISRC with duration verification
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC) GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
// Verify artist AND title
if track != nil { if track != nil {
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
@@ -972,10 +1005,8 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
} }
} }
// Strategy 2: Search by metadata with duration verification (includes title verification)
if track == nil { if track == nil {
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
// Verify artist (title already verified in SearchTrackByMetadataWithDuration)
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name) req.ArtistName, track.Performer.Name)
@@ -991,7 +1022,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
} }
// Log match found and cache the track ID
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration) GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
if req.ISRC != "" { if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID) GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
@@ -1012,22 +1042,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
} }
// Map quality from Tidal format to Qobuz format qobuzQuality := "27"
// Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res)
// Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz)
qobuzQuality := "27" // Default to highest quality
switch req.Quality { switch req.Quality {
case "LOSSLESS": case "LOSSLESS":
qobuzQuality = "6" // 16-bit FLAC qobuzQuality = "6"
case "HI_RES": case "HI_RES":
qobuzQuality = "7" // 24-bit 96kHz qobuzQuality = "7"
case "HI_RES_LOSSLESS": case "HI_RES_LOSSLESS":
qobuzQuality = "27" // 24-bit 192kHz qobuzQuality = "27"
} }
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
actualBitDepth := track.MaximumBitDepth actualBitDepth := track.MaximumBitDepth
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz actualSampleRate := int(track.MaximumSamplingRate * 1000)
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate) GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality) downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
@@ -1035,7 +1062,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
} }
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{}) parallelDone := make(chan struct{})
go func() { go func() {
@@ -1051,7 +1077,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, 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.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) { if errors.Is(err, ErrDownloadCancelled) {
return QobuzDownloadResult{}, ErrDownloadCancelled return QobuzDownloadResult{}, ErrDownloadCancelled
@@ -1059,7 +1084,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err) return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
} }
// Wait for parallel operations to complete
<-parallelDone <-parallelDone
if req.ItemID != "" { if req.ItemID != "" {
@@ -1072,7 +1096,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
albumName = req.AlbumName albumName = req.AlbumName
} }
// Use track number from request if available, otherwise from Qobuz API
actualTrackNumber := req.TrackNumber actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 { if actualTrackNumber == 0 {
actualTrackNumber = track.TrackNumber actualTrackNumber = track.TrackNumber
@@ -1082,15 +1105,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Title: track.Title, Title: track.Title,
Artist: track.Performer.Name, Artist: track.Performer.Name,
Album: albumName, Album: albumName,
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct AlbumArtist: req.AlbumArtist,
Date: track.Album.ReleaseDate, Date: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber, TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result DiscNumber: req.DiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
Genre: req.Genre, // From Deezer album metadata Genre: req.Genre,
Label: req.Label, // From Deezer album metadata Label: req.Label,
Copyright: req.Copyright, // From Deezer album metadata Copyright: req.Copyright,
} }
var coverData []byte var coverData []byte
@@ -1130,7 +1153,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
fmt.Println("[Qobuz] No lyrics available from parallel fetch") fmt.Println("[Qobuz] No lyrics available from parallel fetch")
} }
// Add to ISRC index for fast duplicate checking
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
return QobuzDownloadResult{ return QobuzDownloadResult{
@@ -1142,7 +1164,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Album: track.Album.Title, Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate, ReleaseDate: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber, TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber, // Qobuz track struct limitations DiscNumber: req.DiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
}, nil }, nil
} }
+28 -27
View File
@@ -43,10 +43,6 @@ func NewSongLinkClient() *SongLinkClient {
} }
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
if spotifyTrackID == "" {
return nil, fmt.Errorf("spotify track ID is empty")
}
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
@@ -115,10 +111,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
} }
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
return availability, nil return availability, nil
} }
@@ -191,11 +183,11 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
if err != nil { if err != nil {
return "", err return "", err
} }
if !availability.Deezer || availability.DeezerID == "" { if !availability.Deezer || availability.DeezerID == "" {
return "", fmt.Errorf("track not found on Deezer") return "", fmt.Errorf("track not found on Deezer")
} }
return availability.DeezerID, nil return availability.DeezerID, nil
} }
@@ -208,10 +200,8 @@ type AlbumAvailability struct {
} }
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
// Use global rate limiter
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
// Build API URL for album
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID) spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
@@ -268,11 +258,11 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
if err != nil { if err != nil {
return "", err return "", err
} }
if !availability.Deezer || availability.DeezerID == "" { if !availability.Deezer || availability.DeezerID == "" {
return "", fmt.Errorf("album not found on Deezer") return "", fmt.Errorf("album not found on Deezer")
} }
return availability.DeezerID, nil return availability.DeezerID, nil
} }
@@ -281,7 +271,23 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
if deezerTrackID == "" { if deezerTrackID == "" {
return nil, fmt.Errorf("deezer track ID is empty") return nil, fmt.Errorf("deezer track ID is empty")
} }
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
if err != nil {
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
idhsClient := NewIDHSClient()
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
if err != nil {
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
}
LogInfo("SongLink", "IDHS fallback successful for Deezer %s", deezerTrackID)
}
return availability, nil
}
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
@@ -301,7 +307,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
} }
defer resp.Body.Close() defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 { if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)") return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
} }
@@ -369,12 +374,9 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
if entityID == "" { if entityID == "" {
return nil, fmt.Errorf("%s ID is empty", platform) return nil, fmt.Errorf("%s ID is empty", platform)
} }
// Use global rate limiter
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
// Build API URL using platform, type, and id parameters (as per API docs)
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US", apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
url.QueryEscape(platform), url.QueryEscape(platform),
url.QueryEscape(entityType), url.QueryEscape(entityType),
@@ -392,7 +394,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
} }
defer resp.Body.Close() defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 { if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform) return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
} }
@@ -464,11 +465,11 @@ func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, e
if err != nil { if err != nil {
return "", err return "", err
} }
if availability.SpotifyID == "" { if availability.SpotifyID == "" {
return "", fmt.Errorf("track not found on Spotify") return "", fmt.Errorf("track not found on Spotify")
} }
return availability.SpotifyID, nil return availability.SpotifyID, nil
} }
@@ -478,11 +479,11 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
if err != nil { if err != nil {
return "", err return "", err
} }
if !availability.Tidal || availability.TidalURL == "" { if !availability.Tidal || availability.TidalURL == "" {
return "", fmt.Errorf("track not found on Tidal") return "", fmt.Errorf("track not found on Tidal")
} }
return availability.TidalURL, nil return availability.TidalURL, nil
} }
@@ -491,10 +492,10 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
if err != nil { if err != nil {
return "", err return "", err
} }
if !availability.Amazon || availability.AmazonURL == "" { if !availability.Amazon || availability.AmazonURL == "" {
return "", fmt.Errorf("track not found on Amazon Music") return "", fmt.Errorf("track not found on Amazon Music")
} }
return availability.AmazonURL, nil return availability.AmazonURL, nil
} }
+22 -4
View File
@@ -66,7 +66,6 @@ var (
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured // 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)") 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)")
// SetSpotifyCredentials sets custom Spotify API credentials
func SetSpotifyCredentials(clientID, clientSecret string) { func SetSpotifyCredentials(clientID, clientSecret string) {
credentialsMu.Lock() credentialsMu.Lock()
defer credentialsMu.Unlock() defer credentialsMu.Unlock()
@@ -89,7 +88,6 @@ func HasSpotifyCredentials() bool {
return false return false
} }
// getCredentials returns the current credentials or error if not configured
func getCredentials() (string, string, error) { func getCredentials() (string, string, error) {
credentialsMu.RLock() credentialsMu.RLock()
defer credentialsMu.RUnlock() defer credentialsMu.RUnlock()
@@ -238,9 +236,29 @@ type SearchArtistResult struct {
Popularity int `json:"popularity"` Popularity int `json:"popularity"`
} }
type SearchAlbumResult struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TotalTracks int `json:"total_tracks"`
AlbumType string `json:"album_type"`
}
type SearchPlaylistResult struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Images string `json:"images"`
TotalTracks int `json:"total_tracks"`
}
type SearchAllResult struct { type SearchAllResult struct {
Tracks []TrackMetadata `json:"tracks"` Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"` Artists []SearchArtistResult `json:"artists"`
Albums []SearchAlbumResult `json:"albums"`
Playlists []SearchPlaylistResult `json:"playlists"`
} }
type spotifyURI struct { type spotifyURI struct {
+88 -37
View File
@@ -119,17 +119,18 @@ func NewTidalDownloader() *TidalDownloader {
return globalTidalDownloader return globalTidalDownloader
} }
// GetAvailableAPIs returns list of available Tidal APIs
func (t *TidalDownloader) GetAvailableAPIs() []string { func (t *TidalDownloader) GetAvailableAPIs() []string {
encodedAPIs := []string{ encodedAPIs := []string{
"dGlkYWwua2lub3BsdXMub25saW5l", "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority)
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", "dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
"dHJpdG9uLnNxdWlkLnd0Zg==", "dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
"dm9nZWwucXFkbC5zaXRl", "dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
"bWF1cy5xcWRsLnNpdGU=", "bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
"aHVuZC5xcWRsLnNpdGU=", "aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
"a2F0emUucXFkbC5zaXRl", "a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
"d29sZi5xcWRsLnNpdGU=", "d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
} }
var apis []string var apis []string
@@ -249,7 +250,6 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
return trackID, nil return trackID, nil
} }
// GetTrackInfoByID gets track info by Tidal track ID
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
token, err := t.GetAccessToken() token, err := t.GetAccessToken()
if err != nil { if err != nil {
@@ -442,13 +442,13 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
durationDiff = -durationDiff durationDiff = -durationDiff
} }
if durationDiff <= 3 { if durationDiff <= 3 {
GoLog("[Tidal] ISRC match: '%s' (duration verified)\n", track.Title) GoLog("[Tidal] ISRC match: '%s' (duration verified)\n", track.Title)
return track, nil return track, nil
} }
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n", GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
expectedDuration, track.Duration) expectedDuration, track.Duration)
} else { } else {
GoLog("[Tidal] ISRC match: '%s'\n", track.Title) GoLog("[Tidal] ISRC match: '%s'\n", track.Title)
return track, nil return track, nil
} }
} }
@@ -487,7 +487,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
} }
if len(durationVerifiedMatches) > 0 { if len(durationVerifiedMatches) > 0 {
GoLog("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", GoLog("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration) durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil return durationVerifiedMatches[0], nil
} }
@@ -498,11 +498,11 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
expectedDuration, isrcMatches[0].Duration) expectedDuration, isrcMatches[0].Duration)
} }
GoLog("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) GoLog("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil return isrcMatches[0], nil
} }
GoLog("[Tidal] No ISRC match found for: %s\n", spotifyISRC) GoLog("[Tidal] No ISRC match found for: %s\n", spotifyISRC)
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
} }
@@ -669,7 +669,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
for i := 0; i < len(apis); i++ { for i := 0; i < len(apis); i++ {
result := <-resultChan result := <-resultChan
if result.err == nil { if result.err == nil {
GoLog("[Tidal] [Parallel] Got response from %s (%d-bit/%dHz) in %v\n", GoLog("[Tidal] [Parallel] Got response from %s (%d-bit/%dHz) in %v\n",
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration) result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
go func(remaining int) { go func(remaining int) {
@@ -795,7 +795,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return "", initURL, mediaURLs, nil return "", initURL, mediaURLs, nil
} }
// DownloadFile downloads a file from URL with progress tracking
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background() ctx := context.Background()
@@ -968,7 +967,15 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return nil return nil
} }
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a" // For DASH format, determine correct M4A path
// If outputPath already ends with .m4a, use it directly
// Otherwise, convert .flac to .m4a
var m4aPath string
if strings.HasSuffix(outputPath, ".m4a") {
m4aPath = outputPath
} else {
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
}
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath) GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
out, err := os.Create(m4aPath) out, err := os.Create(m4aPath)
@@ -1094,6 +1101,7 @@ type TidalDownloadResult struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
LyricsLRC string // LRC content for embedding in converted files
} }
func artistsMatch(spotifyArtist, tidalArtist string) bool { func artistsMatch(spotifyArtist, tidalArtist string) bool {
@@ -1104,7 +1112,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return true return true
} }
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) { if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
return true return true
} }
@@ -1163,7 +1170,6 @@ func sameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a) wordsA := strings.Fields(a)
wordsB := strings.Fields(b) wordsB := strings.Fields(b)
// Must have same number of words
if len(wordsA) != len(wordsB) || len(wordsA) == 0 { if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
return false return false
} }
@@ -1196,7 +1202,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle))
// Exact match
if normExpected == normFound { if normExpected == normFound {
return true return true
} }
@@ -1205,7 +1210,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// Clean both titles and compare
cleanExpected := cleanTitle(normExpected) cleanExpected := cleanTitle(normExpected)
cleanFound := cleanTitle(normFound) cleanFound := cleanTitle(normFound)
@@ -1219,7 +1223,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
} }
} }
// Extract core title (before any parentheses/brackets)
coreExpected := extractCoreTitle(normExpected) coreExpected := extractCoreTitle(normExpected)
coreFound := extractCoreTitle(normFound) coreFound := extractCoreTitle(normFound)
@@ -1227,7 +1230,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := isLatinScript(expectedTitle) expectedLatin := isLatinScript(expectedTitle)
foundLatin := isLatinScript(foundTitle) foundLatin := isLatinScript(foundTitle)
if expectedLatin != foundLatin { if expectedLatin != foundLatin {
@@ -1500,6 +1502,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GetTrackIDCache().SetTidal(req.ISRC, track.ID) GetTrackIDCache().SetTidal(req.ISRC, track.ID)
} }
quality := req.Quality
if quality == "" {
quality = "LOSSLESS"
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName, "title": req.TrackName,
"artist": req.ArtistName, "artist": req.ArtistName,
@@ -1508,15 +1515,26 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
"year": extractYear(req.ReleaseDate), "year": extractYear(req.ReleaseDate),
"disc": req.DiscNumber, "disc": req.DiscNumber,
}) })
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename) 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"
}
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
} }
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a" if quality != "HIGH" {
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 { if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
}
} }
tmpPath := outputPath + ".m4a.tmp" tmpPath := outputPath + ".m4a.tmp"
@@ -1525,10 +1543,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
os.Remove(tmpPath) os.Remove(tmpPath)
} }
quality := req.Quality
if quality == "" {
quality = "LOSSLESS"
}
GoLog("[Tidal] Using quality: %s\n", quality) GoLog("[Tidal] Using quality: %s\n", quality)
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality) downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
@@ -1591,7 +1605,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate) GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
} }
// Use track number from request if available, otherwise from Tidal API
actualTrackNumber := req.TrackNumber actualTrackNumber := req.TrackNumber
actualDiscNumber := req.DiscNumber actualDiscNumber := req.DiscNumber
if actualTrackNumber == 0 { if actualTrackNumber == 0 {
@@ -1654,15 +1667,52 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
fmt.Println("[Tidal] No lyrics available from parallel fetch") fmt.Println("[Tidal] No lyrics available from parallel fetch")
} }
} else if strings.HasSuffix(actualOutputPath, ".m4a") { } else if strings.HasSuffix(actualOutputPath, ".m4a") {
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") if quality == "HIGH" {
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if 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)
} else {
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
}
}
}
} else {
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
}
} }
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
bitDepth := downloadInfo.BitDepth
sampleRate := downloadInfo.SampleRate
lyricsLRC := ""
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
}
}
}
return TidalDownloadResult{ return TidalDownloadResult{
FilePath: actualOutputPath, FilePath: actualOutputPath,
BitDepth: downloadInfo.BitDepth, BitDepth: bitDepth,
SampleRate: downloadInfo.SampleRate, SampleRate: sampleRate,
Title: track.Title, Title: track.Title,
Artist: track.Artist.Name, Artist: track.Artist.Name,
Album: track.Album.Title, Album: track.Album.Title,
@@ -1670,5 +1720,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
TrackNumber: actualTrackNumber, TrackNumber: actualTrackNumber,
DiscNumber: actualDiscNumber, DiscNumber: actualDiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil }, nil
} }
+2 -1
View File
@@ -222,7 +222,8 @@ import Gobackend // Import Go framework
let query = args["query"] as! String let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15 let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3 let artistLimit = args["artist_limit"] as? Int ?? 3
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), &error) let filter = args["filter"] as? String ?? ""
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error } if let error = error { throw error }
return response return response
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.2.0'; static const String version = '3.3.5';
static const String buildNumber = '63'; static const String buildNumber = '70';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+165 -15
View File
@@ -952,6 +952,12 @@ abstract class AppLocalizations {
/// **'The original HiFi project creator. The foundation of Tidal integration!'** /// **'The original HiFi project creator. The foundation of Tidal integration!'**
String get aboutSachinsenalDesc; String get aboutSachinsenalDesc;
/// Credit description for sjdonado
///
/// In en, this message translates to:
/// **'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'**
String get aboutSjdonadoDesc;
/// Name of Amazon API service - DO NOT TRANSLATE /// Name of Amazon API service - DO NOT TRANSLATE
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1306,6 +1312,12 @@ abstract class AppLocalizations {
/// **'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'** /// **'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'**
String get setupIosEmptyFolderWarning; String get setupIosEmptyFolderWarning;
/// Error when user selects iCloud Drive on iOS
///
/// In en, this message translates to:
/// **'iCloud Drive is not supported. Please use the app Documents folder.'**
String get setupIcloudNotSupported;
/// App tagline in setup /// App tagline in setup
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2962,6 +2974,24 @@ abstract class AppLocalizations {
/// **'Failed to load lyrics'** /// **'Failed to load lyrics'**
String get trackLyricsLoadFailed; String get trackLyricsLoadFailed;
/// Action - embed lyrics into audio file
///
/// In en, this message translates to:
/// **'Embed Lyrics'**
String get trackEmbedLyrics;
/// Snackbar - lyrics saved to file
///
/// In en, this message translates to:
/// **'Lyrics embedded successfully'**
String get trackLyricsEmbedded;
/// Message when track is instrumental (no lyrics)
///
/// In en, this message translates to:
/// **'Instrumental track'**
String get trackInstrumental;
/// Snackbar - content copied /// Snackbar - content copied
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3376,35 +3406,65 @@ abstract class AppLocalizations {
/// **'24-bit / up to 192kHz'** /// **'24-bit / up to 192kHz'**
String get qualityHiResFlacMaxSubtitle; String get qualityHiResFlacMaxSubtitle;
/// Quality option - MP3 lossy format /// Quality option - lossy format (MP3/Opus)
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'MP3'** /// **'Lossy'**
String get qualityMp3; String get qualityLossy;
/// Technical spec for MP3 /// Technical spec for lossy MP3
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'320kbps (converted from FLAC)'** /// **'MP3 320kbps (converted from FLAC)'**
String get qualityMp3Subtitle; String get qualityLossyMp3Subtitle;
/// Setting - enable MP3 quality option /// Technical spec for lossy Opus
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Enable MP3 Option'** /// **'Opus 128kbps (converted from FLAC)'**
String get enableMp3Option; String get qualityLossyOpusSubtitle;
/// Subtitle when MP3 is enabled /// Setting - enable lossy quality option
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'MP3 quality option is available'** /// **'Enable Lossy Option'**
String get enableMp3OptionSubtitleOn; String get enableLossyOption;
/// Subtitle when MP3 is disabled /// Subtitle when lossy is enabled
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Downloads FLAC then converts to 320kbps MP3'** /// **'Lossy quality option is available'**
String get enableMp3OptionSubtitleOff; String get enableLossyOptionSubtitleOn;
/// Subtitle when lossy is disabled
///
/// In en, this message translates to:
/// **'Downloads FLAC then converts to lossy format'**
String get enableLossyOptionSubtitleOff;
/// Setting - choose lossy format
///
/// In en, this message translates to:
/// **'Lossy Format'**
String get lossyFormat;
/// Description for lossy format picker
///
/// In en, this message translates to:
/// **'Choose the lossy format for conversion'**
String get lossyFormatDescription;
/// MP3 format description
///
/// In en, this message translates to:
/// **'320kbps, best compatibility'**
String get lossyFormatMp3Subtitle;
/// Opus format description
///
/// In en, this message translates to:
/// **'128kbps, better quality at smaller size'**
String get lossyFormatOpusSubtitle;
/// Note about quality availability /// Note about quality availability
/// ///
@@ -3592,6 +3652,42 @@ abstract class AppLocalizations {
/// **'Are you sure you want to clear all downloads?'** /// **'Are you sure you want to clear all downloads?'**
String get queueClearAllMessage; String get queueClearAllMessage;
/// Button - export failed downloads to TXT
///
/// In en, this message translates to:
/// **'Export'**
String get queueExportFailed;
/// Success message after exporting failed downloads
///
/// In en, this message translates to:
/// **'Failed downloads exported to TXT file'**
String get queueExportFailedSuccess;
/// Action to clear failed downloads after export
///
/// In en, this message translates to:
/// **'Clear Failed'**
String get queueExportFailedClear;
/// Error message when export fails
///
/// In en, this message translates to:
/// **'Failed to export downloads'**
String get queueExportFailedError;
/// Setting toggle for auto-export
///
/// In en, this message translates to:
/// **'Auto-export failed downloads'**
String get settingsAutoExportFailed;
/// Subtitle for auto-export setting
///
/// In en, this message translates to:
/// **'Save failed downloads to TXT file automatically'**
String get settingsAutoExportFailedSubtitle;
/// Empty queue state title /// Empty queue state title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3688,6 +3784,18 @@ abstract class AppLocalizations {
/// **'Albums/[2005] Album Name/'** /// **'Albums/[2005] Album Name/'**
String get albumFolderYearAlbumSubtitle; String get albumFolderYearAlbumSubtitle;
/// Album folder option with singles inside artist
///
/// In en, this message translates to:
/// **'Artist / Album + Singles'**
String get albumFolderArtistAlbumSingles;
/// Folder structure example
///
/// In en, this message translates to:
/// **'Artist/Album/ and Artist/Singles/'**
String get albumFolderArtistAlbumSinglesSubtitle;
/// Button - delete selected tracks /// Button - delete selected tracks
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3891,6 +3999,48 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Failed to fetch some albums'** /// **'Failed to fetch some albums'**
String get discographyFailedToFetch; String get discographyFailedToFetch;
/// Section header for storage access settings
///
/// In en, this message translates to:
/// **'Storage Access'**
String get sectionStorageAccess;
/// Toggle for MANAGE_EXTERNAL_STORAGE permission
///
/// In en, this message translates to:
/// **'All Files Access'**
String get allFilesAccess;
/// Subtitle when all files access is enabled
///
/// In en, this message translates to:
/// **'Can write to any folder'**
String get allFilesAccessEnabledSubtitle;
/// Subtitle when all files access is disabled
///
/// In en, this message translates to:
/// **'Limited to media folders only'**
String get allFilesAccessDisabledSubtitle;
/// Description explaining when to enable all files access
///
/// In en, this message translates to:
/// **'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.'**
String get allFilesAccessDescription;
/// Message when permission is permanently denied
///
/// In en, this message translates to:
/// **'Permission was denied. Please enable \'All files access\' manually in system settings.'**
String get allFilesAccessDeniedMessage;
/// Snackbar message when user disables all files access
///
/// In en, this message translates to:
/// **'All Files Access disabled. The app will use limited storage access.'**
String get allFilesAccessDisabledMessage;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+168 -82
View File
@@ -112,7 +112,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Einzelne Titel-Downloads werden hier angezeigt'; 'Einzelne Titel-Downloads werden hier angezeigt';
@override @override
String get historySearchHint => 'Search history...'; String get historySearchHint => 'Suchverlauf...';
@override @override
String get settingsTitle => 'Einstellungen'; String get settingsTitle => 'Einstellungen';
@@ -416,7 +416,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!'; 'Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!';
@override @override
String get aboutTranslators => 'Translators'; String get aboutTranslators => 'Übersetzer';
@override @override
String get aboutSpecialThanks => 'Besonderer Dank'; String get aboutSpecialThanks => 'Besonderer Dank';
@@ -445,19 +445,19 @@ class AppLocalizationsDe extends AppLocalizations {
'Schlage neue Funktionen für die App vor'; 'Schlage neue Funktionen für die App vor';
@override @override
String get aboutTelegramChannel => 'Telegram Channel'; String get aboutTelegramChannel => 'Telegram Kanal';
@override @override
String get aboutTelegramChannelSubtitle => 'Announcements and updates'; String get aboutTelegramChannelSubtitle => 'Ankündigungen und Updates';
@override @override
String get aboutTelegramChat => 'Telegram Community'; String get aboutTelegramChat => 'Telegram Community';
@override @override
String get aboutTelegramChatSubtitle => 'Chat with other users'; String get aboutTelegramChatSubtitle => 'Mit anderen Nutzern chatten';
@override @override
String get aboutSocial => 'Social'; String get aboutSocial => 'Sozial';
@override @override
String get aboutSupport => 'Support'; String get aboutSupport => 'Support';
@@ -483,6 +483,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!'; 'Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -499,7 +503,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
@override @override
String get albumTitle => 'Album'; String get albumTitle => 'Album';
@@ -509,246 +513,252 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
count, count,
locale: localeName, locale: localeName,
other: '$count tracks', other: '$count Songs',
one: '1 track', one: '1 Song',
); );
return '$_temp0'; return '$_temp0';
} }
@override @override
String get albumDownloadAll => 'Download All'; String get albumDownloadAll => 'Alle Herunterladen';
@override @override
String get albumDownloadRemaining => 'Download Remaining'; String get albumDownloadRemaining => 'Downloads verbleibend';
@override @override
String get playlistTitle => 'Playlist'; String get playlistTitle => 'Playlist';
@override @override
String get artistTitle => 'Artist'; String get artistTitle => 'Künstler';
@override @override
String get artistAlbums => 'Albums'; String get artistAlbums => 'Alben';
@override @override
String get artistSingles => 'Singles & EPs'; String get artistSingles => 'Singles & EPs';
@override @override
String get artistCompilations => 'Compilations'; String get artistCompilations => 'Zusammenstellungen';
@override @override
String artistReleases(int count) { String artistReleases(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
count, count,
locale: localeName, locale: localeName,
other: '$count releases', other: '$count Veröffentlichungen',
one: '1 release', one: '1 Veröffentlichung',
); );
return '$_temp0'; return '$_temp0';
} }
@override @override
String get artistPopular => 'Popular'; String get artistPopular => 'Beliebt';
@override @override
String artistMonthlyListeners(String count) { String artistMonthlyListeners(String count) {
return '$count monthly listeners'; return '$count monatliche Hörer';
} }
@override @override
String get trackMetadataTitle => 'Track Info'; String get trackMetadataTitle => 'Titel Info';
@override @override
String get trackMetadataArtist => 'Artist'; String get trackMetadataArtist => 'Künstler';
@override @override
String get trackMetadataAlbum => 'Album'; String get trackMetadataAlbum => 'Album';
@override @override
String get trackMetadataDuration => 'Duration'; String get trackMetadataDuration => 'Länge';
@override @override
String get trackMetadataQuality => 'Quality'; String get trackMetadataQuality => 'Qualität';
@override @override
String get trackMetadataPath => 'File Path'; String get trackMetadataPath => 'Dateipfad';
@override @override
String get trackMetadataDownloadedAt => 'Downloaded'; String get trackMetadataDownloadedAt => 'Heruntergeladen';
@override @override
String get trackMetadataService => 'Service'; String get trackMetadataService => 'Anbieter';
@override @override
String get trackMetadataPlay => 'Play'; String get trackMetadataPlay => 'Abspielen';
@override @override
String get trackMetadataShare => 'Share'; String get trackMetadataShare => 'Teilen';
@override @override
String get trackMetadataDelete => 'Delete'; String get trackMetadataDelete => 'Löschen';
@override @override
String get trackMetadataRedownload => 'Re-download'; String get trackMetadataRedownload => 'Erneut herunterladen';
@override @override
String get trackMetadataOpenFolder => 'Open Folder'; String get trackMetadataOpenFolder => 'Ordner öffnen';
@override @override
String get setupTitle => 'Welcome to SpotiFLAC'; String get setupTitle => 'Willkommen bei SpotiFLAC';
@override @override
String get setupSubtitle => 'Let\'s get you started'; String get setupSubtitle => 'Los geht\'s';
@override @override
String get setupStoragePermission => 'Storage Permission'; String get setupStoragePermission => 'Speicherberechtigung';
@override @override
String get setupStoragePermissionSubtitle => String get setupStoragePermissionSubtitle =>
'Required to save downloaded files'; 'Benötigt um heruntergeladene Dateien zu Speichern';
@override @override
String get setupStoragePermissionGranted => 'Permission granted'; String get setupStoragePermissionGranted => 'Berechtigung erteilt';
@override @override
String get setupStoragePermissionDenied => 'Permission denied'; String get setupStoragePermissionDenied => 'Berechtigung verweigert';
@override @override
String get setupGrantPermission => 'Grant Permission'; String get setupGrantPermission => 'Berechtigung erlauben';
@override @override
String get setupDownloadLocation => 'Download Location'; String get setupDownloadLocation => 'Speicherort';
@override @override
String get setupChooseFolder => 'Choose Folder'; String get setupChooseFolder => 'Ordner wählen';
@override @override
String get setupContinue => 'Continue'; String get setupContinue => 'Fortfahren';
@override @override
String get setupSkip => 'Skip for now'; String get setupSkip => 'Vorerst überspringen';
@override @override
String get setupStorageAccessRequired => 'Storage Access Required'; String get setupStorageAccessRequired => 'Speicherzugriff erforderlich';
@override @override
String get setupStorageAccessMessage => String get setupStorageAccessMessage =>
'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; 'SpotiFLAC benötigt die Berechtigung \"Auf alle Dateien zugreifen\", um Musikdateien in deinen gewählten Ordner zu speichern.';
@override @override
String get setupStorageAccessMessageAndroid11 => String get setupStorageAccessMessageAndroid11 =>
'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; 'Android 11+ benötigt die Berechtigung „Auf alle Dateien“, um Dateien im ausgewählten Download-Ordner zu speichern.';
@override @override
String get setupOpenSettings => 'Open Settings'; String get setupOpenSettings => 'Einstellungen öffnen';
@override @override
String get setupPermissionDeniedMessage => String get setupPermissionDeniedMessage =>
'Permission denied. Please grant all permissions to continue.'; 'Berechtigung verweigert. Bitte erteilen Sie alle Berechtigungen um fortzufahren.';
@override @override
String setupPermissionRequired(String permissionType) { String setupPermissionRequired(String permissionType) {
return '$permissionType Permission Required'; return '$permissionType Zugriff verweigert';
} }
@override @override
String setupPermissionRequiredMessage(String permissionType) { String setupPermissionRequiredMessage(String permissionType) {
return '$permissionType permission is required for the best experience. You can change this later in Settings.'; return '$permissionType Berechtigung ist erforderlich für\ndie beste Benutzererfahrung. Für kannst dies später in den Einstellungen ändern.';
} }
@override @override
String get setupSelectDownloadFolder => 'Select Download Folder'; String get setupSelectDownloadFolder => 'Wähle Download-Ordner aus';
@override @override
String get setupUseDefaultFolder => 'Use Default Folder?'; String get setupUseDefaultFolder => 'Als Standardordner verwenden?';
@override @override
String get setupNoFolderSelected => String get setupNoFolderSelected =>
'No folder selected. Would you like to use the default Music folder?'; 'Kein Ordner ausgewählt. Soll der Standard-Musikordner verwendet werden?';
@override @override
String get setupUseDefault => 'Use Default'; String get setupUseDefault => 'Standart benutzen';
@override @override
String get setupDownloadLocationTitle => 'Download Location'; String get setupDownloadLocationTitle => 'Speicherort';
@override @override
String get setupDownloadLocationIosMessage => String get setupDownloadLocationIosMessage =>
'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; 'Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Sie können sie über die Datei-App aufrufen.';
@override @override
String get setupAppDocumentsFolder => 'App Documents Folder'; String get setupAppDocumentsFolder => 'App-Dokumentenordner';
@override @override
String get setupAppDocumentsFolderSubtitle => String get setupAppDocumentsFolderSubtitle =>
'Recommended - accessible via Files app'; 'Empfohlen - zugänglich über die Datei-App';
@override @override
String get setupChooseFromFiles => 'Choose from Files'; String get setupChooseFromFiles => 'Aus Dateien auswählen';
@override @override
String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; String get setupChooseFromFilesSubtitle =>
'Wählen Sie iCloud oder einen anderen Ort';
@override @override
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS-Einschränkung: Leere Ordner können nicht ausgewählt werden. Wählen Sie einen Ordner mit mindestens einer Datei.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupStepStorage => 'Storage'; String get setupDownloadInFlac => 'Spotify Titel in FLAC herunterladen';
@override @override
String get setupStepNotification => 'Notification'; String get setupStepStorage => 'Speicherort';
@override @override
String get setupStepFolder => 'Folder'; String get setupStepNotification => 'Benachrichtigung';
@override
String get setupStepFolder => 'Ordner';
@override @override
String get setupStepSpotify => 'Spotify'; String get setupStepSpotify => 'Spotify';
@override @override
String get setupStepPermission => 'Permission'; String get setupStepPermission => 'Berechtigung';
@override @override
String get setupStorageGranted => 'Storage Permission Granted!'; String get setupStorageGranted => 'Speicherberechtigung erlaubt!';
@override @override
String get setupStorageRequired => 'Storage Permission Required'; String get setupStorageRequired => 'Speicherzugriff erforderlich';
@override @override
String get setupStorageDescription => String get setupStorageDescription =>
'SpotiFLAC needs storage permission to save your downloaded music files.'; 'SpotiFLAC benötigt Speicherrechte, um die heruntergeladenen Musikdateien zu speichern.';
@override @override
String get setupNotificationGranted => 'Notification Permission Granted!'; String get setupNotificationGranted =>
'Benachrichtigungs-Berechtigung erteilt';
@override @override
String get setupNotificationEnable => 'Enable Notifications'; String get setupNotificationEnable => 'Benachrichtigungen aktivieren';
@override @override
String get setupNotificationDescription => String get setupNotificationDescription =>
'Get notified when downloads complete or require attention.'; 'Benachrichtigt werden, wenn Downloads abgeschlossen sind.';
@override @override
String get setupFolderSelected => 'Download Folder Selected!'; String get setupFolderSelected => 'Download Ordner ausgewählt!';
@override @override
String get setupFolderChoose => 'Choose Download Folder'; String get setupFolderChoose => 'Speicherort auwählen';
@override @override
String get setupFolderDescription => String get setupFolderDescription =>
'Select a folder where your downloaded music will be saved.'; 'Wählen Sie einen Ordner, in dem Ihre heruntergeladene Musik gespeichert wird.';
@override @override
String get setupChangeFolder => 'Change Folder'; String get setupChangeFolder => 'Ordner ändern';
@override @override
String get setupSelectFolder => 'Select Folder'; String get setupSelectFolder => 'Ordner wählen';
@override @override
String get setupSpotifyApiOptional => 'Spotify API (Optional)'; String get setupSpotifyApiOptional => 'Spotify-API (optional)';
@override @override
String get setupSpotifyApiDescription => String get setupSpotifyApiDescription =>
@@ -1631,6 +1641,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1860,20 +1879,36 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
String get qualityNote => String get qualityNote =>
@@ -1970,6 +2005,26 @@ class AppLocalizationsDe extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2019,6 +2074,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2161,4 +2223,28 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
} }
+90 -6
View File
@@ -470,6 +470,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -680,6 +684,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -1618,6 +1626,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,20 +1864,36 @@ class AppLocalizationsEn extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
String get qualityNote => String get qualityNote =>
@@ -1957,6 +1990,26 @@ class AppLocalizationsEn extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2006,6 +2059,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2148,4 +2208,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
} }
+90 -6
View File
@@ -470,6 +470,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -680,6 +684,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -1618,6 +1626,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,20 +1864,36 @@ class AppLocalizationsEs extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
String get qualityNote => String get qualityNote =>
@@ -1957,6 +1990,26 @@ class AppLocalizationsEs extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2006,6 +2059,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2148,6 +2208,30 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
} }
/// The translations for Spanish Castilian, as used in Spain (`es_ES`). /// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+90 -6
View File
@@ -470,6 +470,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -680,6 +684,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -1618,6 +1626,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,20 +1864,36 @@ class AppLocalizationsFr extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
String get qualityNote => String get qualityNote =>
@@ -1957,6 +1990,26 @@ class AppLocalizationsFr extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2006,6 +2059,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2148,4 +2208,28 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
} }
+98 -14
View File
@@ -9,20 +9,20 @@ class AppLocalizationsHi extends AppLocalizations {
AppLocalizationsHi([String locale = 'hi']) : super(locale); AppLocalizationsHi([String locale = 'hi']) : super(locale);
@override @override
String get appName => 'SpotiFLAC'; String get appName => 'SpotiFlac';
@override @override
String get appDescription => String get appDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।';
@override @override
String get navHome => 'Home'; String get navHome => 'होम';
@override @override
String get navHistory => 'History'; String get navHistory => 'इतिहास';
@override @override
String get navSettings => 'Settings'; String get navSettings => 'विकल्प';
@override @override
String get navStore => 'Store'; String get navStore => 'Store';
@@ -184,7 +184,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get quality128 => '128 kbps'; String get quality128 => '128 kbps';
@override @override
String get appearanceTitle => 'Appearance'; String get appearanceTitle => 'दिखावट';
@override @override
String get appearanceTheme => 'Theme'; String get appearanceTheme => 'Theme';
@@ -199,10 +199,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get appearanceThemeDark => 'Dark'; String get appearanceThemeDark => 'Dark';
@override @override
String get appearanceDynamicColor => 'Dynamic Color'; String get appearanceDynamicColor => 'डायनेमिक रंग';
@override @override
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; String get appearanceDynamicColorSubtitle => 'वॉलपेपर से रंग इस्तेमाल करें';
@override @override
String get appearanceAccentColor => 'Accent Color'; String get appearanceAccentColor => 'Accent Color';
@@ -470,6 +470,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -680,6 +684,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -1618,6 +1626,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,20 +1864,36 @@ class AppLocalizationsHi extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
String get qualityNote => String get qualityNote =>
@@ -1957,6 +1990,26 @@ class AppLocalizationsHi extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2006,6 +2059,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2148,4 +2208,28 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
} }
+106 -22
View File
@@ -475,6 +475,10 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!'; 'Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -685,6 +689,10 @@ class AppLocalizationsId extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.'; 'Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC'; String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC';
@@ -1628,6 +1636,15 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Gagal memuat lirik'; String get trackLyricsLoadFailed => 'Gagal memuat lirik';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Disalin ke clipboard'; String get trackCopiedToClipboard => 'Disalin ke clipboard';
@@ -1859,20 +1876,36 @@ class AppLocalizationsId extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (konversi dari FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Aktifkan Opsi MP3'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'Opsi kualitas MP3 tersedia'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Unduh FLAC lalu konversi ke MP3 320kbps';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
String get qualityNote => String get qualityNote =>
@@ -1970,6 +2003,26 @@ class AppLocalizationsId extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Apakah Anda yakin ingin menghapus semua unduhan?'; 'Apakah Anda yakin ingin menghapus semua unduhan?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override @override
String get queueEmpty => 'Tidak ada unduhan dalam antrian'; String get queueEmpty => 'Tidak ada unduhan dalam antrian';
@@ -2019,6 +2072,13 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih'; String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
@@ -2097,68 +2157,92 @@ class AppLocalizationsId extends AppLocalizations {
} }
@override @override
String get discographyDownload => 'Unduh Diskografi'; String get discographyDownload => 'Download Discography';
@override @override
String get discographyDownloadAll => 'Unduh Semua'; String get discographyDownloadAll => 'Unduh Semua';
@override @override
String discographyDownloadAllSubtitle(int count, int albumCount) { String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount rilis'; return '$count tracks from $albumCount releases';
} }
@override @override
String get discographyAlbumsOnly => 'Album Saja'; String get discographyAlbumsOnly => 'Albums Only';
@override @override
String discographyAlbumsOnlySubtitle(int count, int albumCount) { String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount album'; return '$count tracks from $albumCount albums';
} }
@override @override
String get discographySinglesOnly => 'Single & EP Saja'; String get discographySinglesOnly => 'Singles & EPs Only';
@override @override
String discographySinglesOnlySubtitle(int count, int albumCount) { String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount single'; return '$count tracks from $albumCount singles';
} }
@override @override
String get discographySelectAlbums => 'Pilih Album...'; String get discographySelectAlbums => 'Select Albums...';
@override @override
String get discographySelectAlbumsSubtitle => String get discographySelectAlbumsSubtitle =>
'Pilih album atau single tertentu'; 'Choose specific albums or singles';
@override @override
String get discographyFetchingTracks => 'Mengambil lagu...'; String get discographyFetchingTracks => 'Fetching tracks...';
@override @override
String discographyFetchingAlbum(int current, int total) { String discographyFetchingAlbum(int current, int total) {
return 'Mengambil $current dari $total...'; return 'Fetching $current of $total...';
} }
@override @override
String discographySelectedCount(int count) { String discographySelectedCount(int count) {
return '$count dipilih'; return '$count selected';
} }
@override @override
String get discographyDownloadSelected => 'Unduh yang Dipilih'; String get discographyDownloadSelected => 'Download Selected';
@override @override
String discographyAddedToQueue(int count) { String discographyAddedToQueue(int count) {
return 'Menambahkan $count lagu ke antrian'; return 'Added $count tracks to queue';
} }
@override @override
String discographySkippedDownloaded(int added, int skipped) { String discographySkippedDownloaded(int added, int skipped) {
return '$added ditambahkan, $skipped sudah diunduh'; return '$added added, $skipped already downloaded';
} }
@override @override
String get discographyNoAlbums => 'Tidak ada album tersedia'; String get discographyNoAlbums => 'No albums available';
@override @override
String get discographyFailedToFetch => 'Gagal mengambil beberapa album'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
} }
File diff suppressed because it is too large Load Diff
+90 -6
View File
@@ -470,6 +470,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -680,6 +684,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -1618,6 +1626,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,20 +1864,36 @@ class AppLocalizationsKo extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
String get qualityNote => String get qualityNote =>
@@ -1957,6 +1990,26 @@ class AppLocalizationsKo extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2006,6 +2059,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2148,4 +2208,28 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
} }
+90 -6
View File
@@ -470,6 +470,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -680,6 +684,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -1618,6 +1626,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,20 +1864,36 @@ class AppLocalizationsNl extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
String get qualityNote => String get qualityNote =>
@@ -1957,6 +1990,26 @@ class AppLocalizationsNl extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2006,6 +2059,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2148,4 +2208,28 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
} }
+246 -155
View File
@@ -470,6 +470,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!'; 'The original HiFi project creator. The foundation of Tidal integration!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -680,6 +684,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -1618,6 +1626,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1847,20 +1864,36 @@ class AppLocalizationsPt extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
String get qualityNote => String get qualityNote =>
@@ -1957,6 +1990,26 @@ class AppLocalizationsPt extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'No downloads in queue';
@@ -2006,6 +2059,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2148,6 +2208,30 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
} }
/// The translations for Portuguese, as used in Portugal (`pt_PT`). /// The translations for Portuguese, as used in Portugal (`pt_PT`).
@@ -2817,32 +2901,32 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
'Limitação do iOS: Pastas vazias não podem ser selecionadas. Escolha uma pasta com pelo menos um arquivo.'; 'Limitação do iOS: Pastas vazias não podem ser selecionadas. Escolha uma pasta com pelo menos um arquivo.';
@override @override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; String get setupDownloadInFlac => 'Baixe faixas do Spotify em FLAC';
@override @override
String get setupStepStorage => 'Storage'; String get setupStepStorage => 'Armazenamento';
@override @override
String get setupStepNotification => 'Notification'; String get setupStepNotification => 'Notificação';
@override @override
String get setupStepFolder => 'Folder'; String get setupStepFolder => 'Pasta';
@override @override
String get setupStepSpotify => 'Spotify'; String get setupStepSpotify => 'Spotify';
@override @override
String get setupStepPermission => 'Permission'; String get setupStepPermission => 'Permissão';
@override @override
String get setupStorageGranted => 'Storage Permission Granted!'; String get setupStorageGranted => 'Permissão de Armazenamento Concedida!';
@override @override
String get setupStorageRequired => 'Storage Permission Required'; String get setupStorageRequired => 'Permissão de Armazenamento Necessária';
@override @override
String get setupStorageDescription => String get setupStorageDescription =>
'SpotiFLAC needs storage permission to save your downloaded music files.'; 'O SpotiFLAC precisa de permissão de armazenamento para salvar os seus arquivos de música baixados.';
@override @override
String get setupNotificationGranted => 'Permissão de Notificações Concedida!'; String get setupNotificationGranted => 'Permissão de Notificações Concedida!';
@@ -3006,171 +3090,172 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
'Você tem certeza que deseja limpar todos os downloads?'; 'Você tem certeza que deseja limpar todos os downloads?';
@override @override
String get dialogRemoveFromDevice => 'Remove from device?'; String get dialogRemoveFromDevice => 'Remover do dispositivo?';
@override @override
String get dialogRemoveExtension => 'Remove Extension'; String get dialogRemoveExtension => 'Remover Extensão';
@override @override
String get dialogRemoveExtensionMessage => String get dialogRemoveExtensionMessage =>
'Are you sure you want to remove this extension? This cannot be undone.'; 'Tem certeza de que deseja remover esta extensão? Isso não pode ser desfeito.';
@override @override
String get dialogUninstallExtension => 'Uninstall Extension?'; String get dialogUninstallExtension => 'Desinstalar Extensão?';
@override @override
String dialogUninstallExtensionMessage(String extensionName) { String dialogUninstallExtensionMessage(String extensionName) {
return 'Are you sure you want to remove $extensionName?'; return 'Tem certeza de que deseja remover $extensionName?';
} }
@override @override
String get dialogClearHistoryTitle => 'Clear History'; String get dialogClearHistoryTitle => 'Limpar Histórico';
@override @override
String get dialogClearHistoryMessage => String get dialogClearHistoryMessage =>
'Are you sure you want to clear all download history? This cannot be undone.'; 'Tem certeza de que deseja limpar todo o histórico de downloads? Isso não pode ser desfeito.';
@override @override
String get dialogDeleteSelectedTitle => 'Delete Selected'; String get dialogDeleteSelectedTitle => 'Apagar Selecionados';
@override @override
String dialogDeleteSelectedMessage(int count) { String dialogDeleteSelectedMessage(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
count, count,
locale: localeName, locale: localeName,
other: 'tracks', other: 'faixas',
one: 'track', one: 'faixa',
); );
return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; return 'Apagar $count $_temp0 do histórico?\n\nIsso também apagará os arquivos do armazenamento.';
} }
@override @override
String get dialogImportPlaylistTitle => 'Import Playlist'; String get dialogImportPlaylistTitle => 'Importar Playlist';
@override @override
String dialogImportPlaylistMessage(int count) { String dialogImportPlaylistMessage(int count) {
return 'Found $count tracks in CSV. Add them to download queue?'; return 'Encontradas $count faixas no CSV. Adicionar à fila de download?';
} }
@override @override
String snackbarAddedToQueue(String trackName) { String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue'; return '\"$trackName\" adicionada à fila';
} }
@override @override
String snackbarAddedTracksToQueue(int count) { String snackbarAddedTracksToQueue(int count) {
return 'Added $count tracks to queue'; return '$count faixas adicionadas à fila';
} }
@override @override
String snackbarAlreadyDownloaded(String trackName) { String snackbarAlreadyDownloaded(String trackName) {
return '\"$trackName\" already downloaded'; return '\"$trackName\" já foi baixada';
} }
@override @override
String get snackbarHistoryCleared => 'History cleared'; String get snackbarHistoryCleared => 'Histórico limpo';
@override @override
String get snackbarCredentialsSaved => 'Credentials saved'; String get snackbarCredentialsSaved => 'Credenciais salvas';
@override @override
String get snackbarCredentialsCleared => 'Credentials cleared'; String get snackbarCredentialsCleared => 'Credenciais removidas';
@override @override
String snackbarDeletedTracks(int count) { String snackbarDeletedTracks(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
count, count,
locale: localeName, locale: localeName,
other: 'tracks', other: 'faixas apagadas',
one: 'track', one: 'faixa apagada',
); );
return 'Deleted $count $_temp0'; return '$count $_temp0';
} }
@override @override
String snackbarCannotOpenFile(String error) { String snackbarCannotOpenFile(String error) {
return 'Cannot open file: $error'; return 'Não foi possível abrir o arquivo: $error';
} }
@override @override
String get snackbarFillAllFields => 'Please fill all fields'; String get snackbarFillAllFields => 'Por favor, preencha todos os campos';
@override @override
String get snackbarViewQueue => 'View Queue'; String get snackbarViewQueue => 'Ver Fila';
@override @override
String snackbarFailedToLoad(String error) { String snackbarFailedToLoad(String error) {
return 'Failed to load: $error'; return 'Falha ao carregar: $error';
} }
@override @override
String snackbarUrlCopied(String platform) { String snackbarUrlCopied(String platform) {
return '$platform URL copied to clipboard'; return 'URL do $platform copiada para a área de transferência';
} }
@override @override
String get snackbarFileNotFound => 'File not found'; String get snackbarFileNotFound => 'Arquivo não encontrado';
@override @override
String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; String get snackbarSelectExtFile =>
'Por favor, selecione um arquivo .spotiflac-ext';
@override @override
String get snackbarProviderPrioritySaved => 'Provider priority saved'; String get snackbarProviderPrioritySaved => 'Prioridade de provedor salva';
@override @override
String get snackbarMetadataProviderSaved => String get snackbarMetadataProviderSaved =>
'Metadata provider priority saved'; 'Prioridade de provedor de metadados salva';
@override @override
String snackbarExtensionInstalled(String extensionName) { String snackbarExtensionInstalled(String extensionName) {
return '$extensionName installed.'; return '$extensionName instalada.';
} }
@override @override
String snackbarExtensionUpdated(String extensionName) { String snackbarExtensionUpdated(String extensionName) {
return '$extensionName updated.'; return '$extensionName atualizada.';
} }
@override @override
String get snackbarFailedToInstall => 'Failed to install extension'; String get snackbarFailedToInstall => 'Falha ao instalar extensão';
@override @override
String get snackbarFailedToUpdate => 'Failed to update extension'; String get snackbarFailedToUpdate => 'Falha ao atualizar extensão';
@override @override
String get errorRateLimited => 'Rate Limited'; String get errorRateLimited => 'Taxa Limitada';
@override @override
String get errorRateLimitedMessage => String get errorRateLimitedMessage =>
'Too many requests. Please wait a moment before searching again.'; 'Muitas solicitações. Por favor, aguarde um momento antes de pesquisar novamente.';
@override @override
String errorFailedToLoad(String item) { String errorFailedToLoad(String item) {
return 'Failed to load $item'; return 'Falha ao carregar $item';
} }
@override @override
String get errorNoTracksFound => 'No tracks found'; String get errorNoTracksFound => 'Nenhuma faixa encontrada';
@override @override
String errorMissingExtensionSource(String item) { String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source'; return 'Não foi possível carregar $item: fonte de extensão ausente';
} }
@override @override
String get statusQueued => 'Queued'; String get statusQueued => 'Na Fila';
@override @override
String get statusDownloading => 'Downloading'; String get statusDownloading => 'Baixando';
@override @override
String get statusFinalizing => 'Finalizing'; String get statusFinalizing => 'Finalizando';
@override @override
String get statusCompleted => 'Completed'; String get statusCompleted => 'Concluído';
@override @override
String get statusFailed => 'Failed'; String get statusFailed => 'Falhou';
@override @override
String get statusSkipped => 'Ignorado'; String get statusSkipped => 'Ignorado';
@@ -3507,42 +3592,43 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
String get logNetworkErrorDescription => 'Problemas de conexão detectados'; String get logNetworkErrorDescription => 'Problemas de conexão detectados';
@override @override
String get logNetworkErrorSuggestion => 'Check your internet connection'; String get logNetworkErrorSuggestion =>
'Verifique a sua conexão com a internet';
@override @override
String get logTrackNotFoundDescription => String get logTrackNotFoundDescription =>
'Some tracks could not be found on download services'; 'Algumas faixas não foram encontradas nos serviços de download';
@override @override
String get logTrackNotFoundSuggestion => String get logTrackNotFoundSuggestion =>
'The track may not be available in lossless quality'; 'A faixa pode não estar disponível em qualidade lossless';
@override @override
String logTotalErrors(int count) { String logTotalErrors(int count) {
return 'Total errors: $count'; return 'Total de erros: $count';
} }
@override @override
String logAffected(String domains) { String logAffected(String domains) {
return 'Affected: $domains'; return 'Afetados: $domains';
} }
@override @override
String logEntriesFiltered(int count) { String logEntriesFiltered(int count) {
return 'Entries ($count filtered)'; return 'Entradas ($count filtradas)';
} }
@override @override
String logEntries(int count) { String logEntries(int count) {
return 'Entries ($count)'; return 'Entradas ($count)';
} }
@override @override
String get credentialsTitle => 'Spotify Credentials'; String get credentialsTitle => 'Credenciais do Spotify';
@override @override
String get credentialsDescription => String get credentialsDescription =>
'Enter your Client ID and Secret to use your own Spotify application quota.'; 'Insira o seu Client ID e Secret para usar a sua própria cota de aplicativo do Spotify.';
@override @override
String get credentialsClientId => 'Client ID'; String get credentialsClientId => 'Client ID';
@@ -3707,136 +3793,138 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
String get trackDownloaded => 'Baixado'; String get trackDownloaded => 'Baixado';
@override @override
String get trackCopyLyrics => 'Copy lyrics'; String get trackCopyLyrics => 'Copiar letras';
@override @override
String get trackLyricsNotAvailable => 'Lyrics not available for this track'; String get trackLyricsNotAvailable =>
'Letras não disponíveis para esta faixa';
@override @override
String get trackLyricsTimeout => 'Request timed out. Try again later.'; String get trackLyricsTimeout =>
'A solicitação expirou. Tente novamente mais tarde.';
@override @override
String get trackLyricsLoadFailed => 'Failed to load lyrics'; String get trackLyricsLoadFailed => 'Falha ao carregar letras';
@override @override
String get trackCopiedToClipboard => 'Copied to clipboard'; String get trackCopiedToClipboard => 'Copiado para a área de transferência';
@override @override
String get trackDeleteConfirmTitle => 'Remove from device?'; String get trackDeleteConfirmTitle => 'Remover do dispositivo?';
@override @override
String get trackDeleteConfirmMessage => String get trackDeleteConfirmMessage =>
'This will permanently delete the downloaded file and remove it from your history.'; 'Isso apagará permanentemente o arquivo baixado e o removerá do seu histórico.';
@override @override
String trackCannotOpen(String message) { String trackCannotOpen(String message) {
return 'Cannot open: $message'; return 'Não foi possível abrir: $message';
} }
@override @override
String get dateToday => 'Today'; String get dateToday => 'Hoje';
@override @override
String get dateYesterday => 'Yesterday'; String get dateYesterday => 'Ontem';
@override @override
String dateDaysAgo(int count) { String dateDaysAgo(int count) {
return '$count days ago'; return '$count dias';
} }
@override @override
String dateWeeksAgo(int count) { String dateWeeksAgo(int count) {
return '$count weeks ago'; return '$count semanas';
} }
@override @override
String dateMonthsAgo(int count) { String dateMonthsAgo(int count) {
return '$count months ago'; return '$count meses';
} }
@override @override
String get concurrentSequential => 'Sequential'; String get concurrentSequential => 'Sequencial';
@override @override
String get concurrentParallel2 => '2 Parallel'; String get concurrentParallel2 => '2 Paralelos';
@override @override
String get concurrentParallel3 => '3 Parallel'; String get concurrentParallel3 => '3 Paralelos';
@override @override
String get tapToSeeError => 'Tap to see error details'; String get tapToSeeError => 'Toque para ver detalhes do erro';
@override @override
String get storeFilterAll => 'All'; String get storeFilterAll => 'Todos';
@override @override
String get storeFilterMetadata => 'Metadata'; String get storeFilterMetadata => 'Metadados';
@override @override
String get storeFilterDownload => 'Download'; String get storeFilterDownload => 'Download';
@override @override
String get storeFilterUtility => 'Utility'; String get storeFilterUtility => 'Utilitário';
@override @override
String get storeFilterLyrics => 'Lyrics'; String get storeFilterLyrics => 'Letras';
@override @override
String get storeFilterIntegration => 'Integration'; String get storeFilterIntegration => 'Integração';
@override @override
String get storeClearFilters => 'Clear filters'; String get storeClearFilters => 'Limpar filtros';
@override @override
String get storeNoResults => 'No extensions found'; String get storeNoResults => 'Nenhuma extensão encontrada';
@override @override
String get extensionProviderPriority => 'Provider Priority'; String get extensionProviderPriority => 'Prioridade de Provedor';
@override @override
String get extensionInstallButton => 'Install Extension'; String get extensionInstallButton => 'Instalar Extensão';
@override @override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; String get extensionDefaultProvider => 'Padrão (Deezer/Spotify)';
@override @override
String get extensionDefaultProviderSubtitle => 'Use built-in search'; String get extensionDefaultProviderSubtitle => 'Usar pesquisa integrada';
@override @override
String get extensionAuthor => 'Author'; String get extensionAuthor => 'Autor';
@override @override
String get extensionId => 'ID'; String get extensionId => 'ID';
@override @override
String get extensionError => 'Error'; String get extensionError => 'Erro';
@override @override
String get extensionCapabilities => 'Capabilities'; String get extensionCapabilities => 'Capacidades';
@override @override
String get extensionMetadataProvider => 'Metadata Provider'; String get extensionMetadataProvider => 'Provedor de Metadados';
@override @override
String get extensionDownloadProvider => 'Download Provider'; String get extensionDownloadProvider => 'Provedor de Download';
@override @override
String get extensionLyricsProvider => 'Lyrics Provider'; String get extensionLyricsProvider => 'Provedor de Letras';
@override @override
String get extensionUrlHandler => 'URL Handler'; String get extensionUrlHandler => 'Manipulador de URL';
@override @override
String get extensionQualityOptions => 'Quality Options'; String get extensionQualityOptions => 'Opções de Qualidade';
@override @override
String get extensionPostProcessingHooks => 'Post-Processing Hooks'; String get extensionPostProcessingHooks => 'Ganchos de Pós-Processamento';
@override @override
String get extensionPermissions => 'Permissions'; String get extensionPermissions => 'Permissões';
@override @override
String get extensionSettings => 'Settings'; String get extensionSettings => 'Configurações';
@override @override
String get extensionRemoveButton => 'Remover Extensão'; String get extensionRemoveButton => 'Remover Extensão';
@@ -3987,25 +4075,27 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
String get folderNone => 'Nenhum'; String get folderNone => 'Nenhum';
@override @override
String get folderNoneSubtitle => 'Save all files directly to download folder'; String get folderNoneSubtitle =>
'Salvar todos os arquivos diretamente na pasta de download';
@override @override
String get folderArtist => 'Artist'; String get folderArtist => 'Artista';
@override @override
String get folderArtistSubtitle => 'Artist Name/filename'; String get folderArtistSubtitle => 'Nome do Artista/nome do arquivo';
@override @override
String get folderAlbum => 'Album'; String get folderAlbum => 'Álbum';
@override @override
String get folderAlbumSubtitle => 'Album Name/filename'; String get folderAlbumSubtitle => 'Nome do Álbum/nome do arquivo';
@override @override
String get folderArtistAlbum => 'Artist/Album'; String get folderArtistAlbum => 'Artista/Álbum';
@override @override
String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; String get folderArtistAlbumSubtitle =>
'Nome do Artista/Nome do Álbum/nome do arquivo';
@override @override
String get serviceTidal => 'Tidal'; String get serviceTidal => 'Tidal';
@@ -4023,134 +4113,135 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
String get serviceSpotify => 'Spotify'; String get serviceSpotify => 'Spotify';
@override @override
String get appearanceAmoledDark => 'AMOLED Dark'; String get appearanceAmoledDark => 'AMOLED Escuro';
@override @override
String get appearanceAmoledDarkSubtitle => 'Pure black background'; String get appearanceAmoledDarkSubtitle => 'Fundo preto puro';
@override @override
String get appearanceChooseAccentColor => 'Choose Accent Color'; String get appearanceChooseAccentColor => 'Escolher Cor de Destaque';
@override @override
String get appearanceChooseTheme => 'Theme Mode'; String get appearanceChooseTheme => 'Modo de Tema';
@override @override
String get queueTitle => 'Download Queue'; String get queueTitle => 'Fila de Download';
@override @override
String get queueClearAll => 'Clear All'; String get queueClearAll => 'Limpar Tudo';
@override @override
String get queueClearAllMessage => String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?'; 'Tem certeza de que deseja limpar todos os downloads?';
@override @override
String get queueEmpty => 'No downloads in queue'; String get queueEmpty => 'Nenhum download na fila';
@override @override
String get queueEmptySubtitle => 'Add tracks from the home screen'; String get queueEmptySubtitle => 'Adicione faixas a partir da tela inicial';
@override @override
String get queueClearCompleted => 'Clear completed'; String get queueClearCompleted => 'Limpar concluídos';
@override @override
String get queueDownloadFailed => 'Download Failed'; String get queueDownloadFailed => 'Download Falhou';
@override @override
String get queueTrackLabel => 'Track:'; String get queueTrackLabel => 'Faixa:';
@override @override
String get queueArtistLabel => 'Artist:'; String get queueArtistLabel => 'Artista:';
@override @override
String get queueErrorLabel => 'Error:'; String get queueErrorLabel => 'Erro:';
@override @override
String get queueUnknownError => 'Unknown error'; String get queueUnknownError => 'Erro desconhecido';
@override @override
String get albumFolderArtistAlbum => 'Artist / Album'; String get albumFolderArtistAlbum => 'Artista / Álbum';
@override @override
String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; String get albumFolderArtistAlbumSubtitle =>
'Álbuns/Nome do Artista/Nome do Álbum/';
@override @override
String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; String get albumFolderArtistYearAlbum => 'Artista / [Ano] Álbum';
@override @override
String get albumFolderArtistYearAlbumSubtitle => String get albumFolderArtistYearAlbumSubtitle =>
'Albums/Artist Name/[2005] Album Name/'; 'Álbuns/Nome do Artista/[2005] Nome do Álbum/';
@override @override
String get albumFolderAlbumOnly => 'Album Only'; String get albumFolderAlbumOnly => 'Apenas Álbum';
@override @override
String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; String get albumFolderAlbumOnlySubtitle => 'Álbuns/Nome do Álbum/';
@override @override
String get albumFolderYearAlbum => '[Year] Album'; String get albumFolderYearAlbum => '[Ano] Álbum';
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Álbuns/[2005] Nome do Álbum/';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Apagar Selecionados';
@override @override
String downloadedAlbumDeleteMessage(int count) { String downloadedAlbumDeleteMessage(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
count, count,
locale: localeName, locale: localeName,
other: 'tracks', other: 'faixas',
one: 'track', one: 'faixa',
); );
return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; return 'Apagar $count $_temp0 deste álbum?\n\nIsso também apagará os arquivos do armazenamento.';
} }
@override @override
String get downloadedAlbumTracksHeader => 'Tracks'; String get downloadedAlbumTracksHeader => 'Faixas';
@override @override
String downloadedAlbumDownloadedCount(int count) { String downloadedAlbumDownloadedCount(int count) {
return '$count downloaded'; return '$count baixadas';
} }
@override @override
String downloadedAlbumSelectedCount(int count) { String downloadedAlbumSelectedCount(int count) {
return '$count selected'; return '$count selecionadas';
} }
@override @override
String get downloadedAlbumAllSelected => 'All tracks selected'; String get downloadedAlbumAllSelected => 'Todas as faixas selecionadas';
@override @override
String get downloadedAlbumTapToSelect => 'Tap tracks to select'; String get downloadedAlbumTapToSelect => 'Toque nas faixas para selecionar';
@override @override
String downloadedAlbumDeleteCount(int count) { String downloadedAlbumDeleteCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
count, count,
locale: localeName, locale: localeName,
other: 'tracks', other: 'faixas',
one: 'track', one: 'faixa',
); );
return 'Delete $count $_temp0'; return 'Apagar $count $_temp0';
} }
@override @override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; String get downloadedAlbumSelectToDelete => 'Selecione faixas para apagar';
@override @override
String get utilityFunctions => 'Utility Functions'; String get utilityFunctions => 'Funções Utilitárias';
@override @override
String get recentTypeArtist => 'Artist'; String get recentTypeArtist => 'Artista';
@override @override
String get recentTypeAlbum => 'Album'; String get recentTypeAlbum => 'Álbum';
@override @override
String get recentTypeSong => 'Song'; String get recentTypeSong => 'Música';
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@@ -4162,6 +4253,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override @override
String errorGeneric(String message) { String errorGeneric(String message) {
return 'Error: $message'; return 'Erro: $message';
} }
} }
+139 -54
View File
@@ -74,9 +74,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: '$count треков', other: '$count треков',
one: '1 трек',
many: '$count треков', many: '$count треков',
few: '$count трека', few: '$count трека',
one: '$count трек',
); );
return '$_temp0'; return '$_temp0';
} }
@@ -87,9 +87,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: '$count альбомов', other: '$count альбомов',
one: '1 альбом',
many: '$count альбомов', many: '$count альбомов',
few: '$count альбома', few: '$count альбома',
one: '$count альбом',
); );
return '$_temp0'; return '$_temp0';
} }
@@ -115,7 +115,7 @@ class AppLocalizationsRu extends AppLocalizations {
'Здесь будут отображаться загрузки синглов'; 'Здесь будут отображаться загрузки синглов';
@override @override
String get historySearchHint => 'Search history...'; String get historySearchHint => 'Поиск в истории...';
@override @override
String get settingsTitle => 'Настройки'; String get settingsTitle => 'Настройки';
@@ -418,7 +418,7 @@ class AppLocalizationsRu extends AppLocalizations {
'Талантливый художник, который создал наш красивый логотип приложения!'; 'Талантливый художник, который создал наш красивый логотип приложения!';
@override @override
String get aboutTranslators => 'Translators'; String get aboutTranslators => 'Переводчики';
@override @override
String get aboutSpecialThanks => 'Особая благодарность'; String get aboutSpecialThanks => 'Особая благодарность';
@@ -446,19 +446,19 @@ class AppLocalizationsRu extends AppLocalizations {
'Предложить новые функции для приложения'; 'Предложить новые функции для приложения';
@override @override
String get aboutTelegramChannel => 'Telegram Channel'; String get aboutTelegramChannel => 'Telegram канал';
@override @override
String get aboutTelegramChannelSubtitle => 'Announcements and updates'; String get aboutTelegramChannelSubtitle => 'Объявления и обновления';
@override @override
String get aboutTelegramChat => 'Telegram Community'; String get aboutTelegramChat => 'Сообщество в Telegram';
@override @override
String get aboutTelegramChatSubtitle => 'Chat with other users'; String get aboutTelegramChatSubtitle => 'Чат с другими пользователями';
@override @override
String get aboutSocial => 'Social'; String get aboutSocial => 'Соцсети';
@override @override
String get aboutSupport => 'Поддержка'; String get aboutSupport => 'Поддержка';
@@ -483,6 +483,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get aboutSachinsenalDesc => String get aboutSachinsenalDesc =>
'Оригинальный создатель проекта HiFi. Основатель Tidal интеграции!'; 'Оригинальный создатель проекта HiFi. Основатель Tidal интеграции!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override @override
String get aboutDoubleDouble => 'DoubleDouble'; String get aboutDoubleDouble => 'DoubleDouble';
@@ -510,9 +514,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: '$count треков', other: '$count треков',
one: '1 трек',
many: '$count треков', many: '$count треков',
few: '$count трека', few: '$count трека',
one: '$count трек',
); );
return '$_temp0'; return '$_temp0';
} }
@@ -544,9 +548,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: '$count релизов', other: '$count релизов',
one: '1 релиз',
many: '$count релизов', many: '$count релизов',
few: '$count релиза', few: '$count релиза',
one: '$count релиз',
); );
return '$_temp0'; return '$_temp0';
} }
@@ -698,6 +702,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get setupIosEmptyFolderWarning => String get setupIosEmptyFolderWarning =>
'Ограничение iOS: пустые папки не могут быть выбраны. Выберите папку, содержащую хотя бы один файл.'; 'Ограничение iOS: пустые папки не могут быть выбраны. Выберите папку, содержащую хотя бы один файл.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override @override
String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC'; String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC';
@@ -922,9 +930,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: 'треков', other: 'треков',
one: 'трек',
many: 'треков', many: 'треков',
few: 'трека', few: 'трека',
one: 'трек',
); );
return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.'; return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.';
} }
@@ -939,7 +947,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String csvImportTracks(int count) { String csvImportTracks(int count) {
return '$count tracks from CSV'; return '$count треков из CSV';
} }
@override @override
@@ -972,9 +980,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: 'треков', other: 'треков',
one: 'трек',
many: 'треков', many: 'треков',
few: 'трека', few: 'трека',
one: 'трек',
); );
return 'Удалено $count $_temp0'; return 'Удалено $count $_temp0';
} }
@@ -1121,9 +1129,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: 'треков', other: 'треков',
one: 'трек',
many: 'треков', many: 'треков',
few: 'трека', few: 'трека',
one: 'трек',
); );
return 'Удалить $count $_temp0'; return 'Удалить $count $_temp0';
} }
@@ -1482,33 +1490,33 @@ class AppLocalizationsRu extends AppLocalizations {
String get sectionFileSettings => 'Настройки файла'; String get sectionFileSettings => 'Настройки файла';
@override @override
String get sectionLyrics => 'Lyrics'; String get sectionLyrics => 'Тексты песен';
@override @override
String get lyricsMode => 'Lyrics Mode'; String get lyricsMode => 'Режим текстов песен';
@override @override
String get lyricsModeDescription => String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads'; 'Выберите как сохранить тексты песен при скачивании';
@override @override
String get lyricsModeEmbed => 'Embed in file'; String get lyricsModeEmbed => 'Вставить в файл';
@override @override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; String get lyricsModeEmbedSubtitle => 'Встроить текст в метаданные FLAC';
@override @override
String get lyricsModeExternal => 'External .lrc file'; String get lyricsModeExternal => 'Внешний файл .lrc';
@override @override
String get lyricsModeExternalSubtitle => String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music'; 'Отдельный файл .lrc для плееров, таких, как Samsung Music';
@override @override
String get lyricsModeBoth => 'Both'; String get lyricsModeBoth => 'Оба варианта';
@override @override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; String get lyricsModeBothSubtitle => 'Вставить и сохранить файл .lrc';
@override @override
String get sectionColor => 'Цвет'; String get sectionColor => 'Цвет';
@@ -1565,9 +1573,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: '$count треков', other: '$count треков',
one: '1 трек',
many: '$count треков', many: '$count треков',
few: '$count трека', few: '$count трека',
one: '$count трек',
); );
return '$_temp0'; return '$_temp0';
} }
@@ -1627,13 +1635,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackReleaseDate => 'Дата выхода'; String get trackReleaseDate => 'Дата выхода';
@override @override
String get trackGenre => 'Genre'; String get trackGenre => 'Жанр';
@override @override
String get trackLabel => 'Label'; String get trackLabel => 'Заголовок';
@override @override
String get trackCopyright => 'Copyright'; String get trackCopyright => 'Авторские права';
@override @override
String get trackDownloaded => 'Скачано'; String get trackDownloaded => 'Скачано';
@@ -1652,6 +1660,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни'; String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
@override
String get trackEmbedLyrics => 'Вставить текст песни';
@override
String get trackLyricsEmbedded => 'Текст успешно добавлен';
@override
String get trackInstrumental => 'Инструментальный трек';
@override @override
String get trackCopiedToClipboard => 'Скопировано в буфер обмена'; String get trackCopiedToClipboard => 'Скопировано в буфер обмена';
@@ -1885,20 +1902,36 @@ class AppLocalizationsRu extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц'; String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
@override @override
String get qualityMp3 => 'MP3'; String get qualityLossy => 'Lossy';
@override @override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override @override
String get enableMp3Option => 'Enable MP3 Option'; String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override @override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; String get enableLossyOption => 'Enable Lossy Option';
@override @override
String get enableMp3OptionSubtitleOff => String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
'Downloads FLAC then converts to 320kbps MP3';
@override
String get enableLossyOptionSubtitleOff =>
'Downloads FLAC then converts to lossy format';
@override
String get lossyFormat => 'Lossy Format';
@override
String get lossyFormatDescription => 'Choose the lossy format for conversion';
@override
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
@override
String get lossyFormatOpusSubtitle =>
'128kbps, better quality at smaller size';
@override @override
String get qualityNote => String get qualityNote =>
@@ -1996,6 +2029,26 @@ class AppLocalizationsRu extends AppLocalizations {
String get queueClearAllMessage => String get queueClearAllMessage =>
'Вы уверены, что хотите очистить все загрузки?'; 'Вы уверены, что хотите очистить все загрузки?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override @override
String get queueEmpty => 'Нет загрузок в очереди'; String get queueEmpty => 'Нет загрузок в очереди';
@@ -2047,6 +2100,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get albumFolderYearAlbumSubtitle => String get albumFolderYearAlbumSubtitle =>
'Альбомы/[2005] Название Альбома /'; 'Альбомы/[2005] Название Альбома /';
@override
String get albumFolderArtistAlbumSingles => 'Исполнитель / Альбом + Синглы';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Исполнитель/Альбом и Исполнитель/Сингл/';
@override @override
String get downloadedAlbumDeleteSelected => 'Удалить выбранные'; String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
@@ -2056,9 +2116,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: 'треков', other: 'треков',
one: 'трек',
many: 'треков', many: 'треков',
few: 'трека', few: 'трека',
one: 'трек',
); );
return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.'; return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.';
} }
@@ -2088,9 +2148,9 @@ class AppLocalizationsRu extends AppLocalizations {
count, count,
locale: localeName, locale: localeName,
other: 'треков', other: 'треков',
one: 'трек',
many: 'треков', many: 'треков',
few: 'трека', few: 'трека',
one: 'трек',
); );
return 'Удалить $count $_temp0'; return 'Удалить $count $_temp0';
} }
@@ -2100,7 +2160,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String downloadedAlbumDiscHeader(int discNumber) { String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber'; return 'Диск $discNumber';
} }
@override @override
@@ -2129,68 +2189,93 @@ class AppLocalizationsRu extends AppLocalizations {
} }
@override @override
String get discographyDownload => 'Download Discography'; String get discographyDownload => 'Скачать дискографию';
@override @override
String get discographyDownloadAll => 'Download All'; String get discographyDownloadAll => 'Скачать всё';
@override @override
String discographyDownloadAllSubtitle(int count, int albumCount) { String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases'; return '$count треков из $albumCount релизов';
} }
@override @override
String get discographyAlbumsOnly => 'Albums Only'; String get discographyAlbumsOnly => 'Только альбомы';
@override @override
String discographyAlbumsOnlySubtitle(int count, int albumCount) { String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums'; return '$count треков из $albumCount альбомов';
} }
@override @override
String get discographySinglesOnly => 'Singles & EPs Only'; String get discographySinglesOnly => 'Только синглы и EP';
@override @override
String discographySinglesOnlySubtitle(int count, int albumCount) { String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles'; return '$count треков из $albumCount синглов';
} }
@override @override
String get discographySelectAlbums => 'Select Albums...'; String get discographySelectAlbums => 'Выбрать альбомы...';
@override @override
String get discographySelectAlbumsSubtitle => String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles'; 'Выберите конкретные альбомы или синглы';
@override @override
String get discographyFetchingTracks => 'Fetching tracks...'; String get discographyFetchingTracks => 'Получение треков...';
@override @override
String discographyFetchingAlbum(int current, int total) { String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...'; return 'Получение $current из $total...';
} }
@override @override
String discographySelectedCount(int count) { String discographySelectedCount(int count) {
return '$count selected'; return '$count выбрано';
} }
@override @override
String get discographyDownloadSelected => 'Download Selected'; String get discographyDownloadSelected => 'Скачать выбранное';
@override @override
String discographyAddedToQueue(int count) { String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue'; return 'Добавлено $count треков в очередь';
} }
@override @override
String discographySkippedDownloaded(int added, int skipped) { String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded'; return '$added добавлено, $skipped уже скачано';
} }
@override @override
String get discographyNoAlbums => 'No albums available'; String get discographyNoAlbums => 'Нет доступных альбомов';
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch =>
'Не удалось получить некоторые альбомы';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+321 -68
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Suchverlauf...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Einstellungen", "settingsTitle": "Einstellungen",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Übersetzer",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Besonderer Dank", "aboutSpecialThanks": "Besonderer Dank",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Kanal",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Ankündigungen und Updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Mit anderen Nutzern chatten",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Sozial",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
@@ -588,7 +616,7 @@
"@aboutDabMusicDesc": { "@aboutDabMusicDesc": {
"description": "Credit for DAB Music API" "description": "Credit for DAB Music API"
}, },
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", "aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -596,7 +624,7 @@
"@albumTitle": { "@albumTitle": {
"description": "Album screen title" "description": "Album screen title"
}, },
"albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", "albumTracks": "{count, plural,=1{1 Song} other{{count} Songs}}",
"@albumTracks": { "@albumTracks": {
"description": "Album track count", "description": "Album track count",
"placeholders": { "placeholders": {
@@ -605,11 +633,11 @@
} }
} }
}, },
"albumDownloadAll": "Download All", "albumDownloadAll": "Alle Herunterladen",
"@albumDownloadAll": { "@albumDownloadAll": {
"description": "Button to download all tracks" "description": "Button to download all tracks"
}, },
"albumDownloadRemaining": "Download Remaining", "albumDownloadRemaining": "Downloads verbleibend",
"@albumDownloadRemaining": { "@albumDownloadRemaining": {
"description": "Button to download remaining tracks" "description": "Button to download remaining tracks"
}, },
@@ -617,11 +645,11 @@
"@playlistTitle": { "@playlistTitle": {
"description": "Playlist screen title" "description": "Playlist screen title"
}, },
"artistTitle": "Artist", "artistTitle": "Künstler",
"@artistTitle": { "@artistTitle": {
"description": "Artist screen title" "description": "Artist screen title"
}, },
"artistAlbums": "Albums", "artistAlbums": "Alben",
"@artistAlbums": { "@artistAlbums": {
"description": "Section header for artist albums" "description": "Section header for artist albums"
}, },
@@ -629,11 +657,11 @@
"@artistSingles": { "@artistSingles": {
"description": "Section header for singles/EPs" "description": "Section header for singles/EPs"
}, },
"artistCompilations": "Compilations", "artistCompilations": "Zusammenstellungen",
"@artistCompilations": { "@artistCompilations": {
"description": "Section header for compilations" "description": "Section header for compilations"
}, },
"artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", "artistReleases": "{count, plural,=1{1 Veröffentlichung} other{{count} Veröffentlichungen}}",
"@artistReleases": { "@artistReleases": {
"description": "Artist release count", "description": "Artist release count",
"placeholders": { "placeholders": {
@@ -642,11 +670,11 @@
} }
} }
}, },
"artistPopular": "Popular", "artistPopular": "Beliebt",
"@artistPopular": { "@artistPopular": {
"description": "Section header for popular/top tracks" "description": "Section header for popular/top tracks"
}, },
"artistMonthlyListeners": "{count} monthly listeners", "artistMonthlyListeners": "{count} monatliche Hörer",
"@artistMonthlyListeners": { "@artistMonthlyListeners": {
"description": "Monthly listener count display", "description": "Monthly listener count display",
"placeholders": { "placeholders": {
@@ -656,11 +684,11 @@
} }
} }
}, },
"trackMetadataTitle": "Track Info", "trackMetadataTitle": "Titel Info",
"@trackMetadataTitle": { "@trackMetadataTitle": {
"description": "Track metadata screen title" "description": "Track metadata screen title"
}, },
"trackMetadataArtist": "Artist", "trackMetadataArtist": "Künstler",
"@trackMetadataArtist": { "@trackMetadataArtist": {
"description": "Metadata field - artist name" "description": "Metadata field - artist name"
}, },
@@ -668,111 +696,111 @@
"@trackMetadataAlbum": { "@trackMetadataAlbum": {
"description": "Metadata field - album name" "description": "Metadata field - album name"
}, },
"trackMetadataDuration": "Duration", "trackMetadataDuration": "Länge",
"@trackMetadataDuration": { "@trackMetadataDuration": {
"description": "Metadata field - track length" "description": "Metadata field - track length"
}, },
"trackMetadataQuality": "Quality", "trackMetadataQuality": "Qualität",
"@trackMetadataQuality": { "@trackMetadataQuality": {
"description": "Metadata field - audio quality" "description": "Metadata field - audio quality"
}, },
"trackMetadataPath": "File Path", "trackMetadataPath": "Dateipfad",
"@trackMetadataPath": { "@trackMetadataPath": {
"description": "Metadata field - file location" "description": "Metadata field - file location"
}, },
"trackMetadataDownloadedAt": "Downloaded", "trackMetadataDownloadedAt": "Heruntergeladen",
"@trackMetadataDownloadedAt": { "@trackMetadataDownloadedAt": {
"description": "Metadata field - download date" "description": "Metadata field - download date"
}, },
"trackMetadataService": "Service", "trackMetadataService": "Anbieter",
"@trackMetadataService": { "@trackMetadataService": {
"description": "Metadata field - download service used" "description": "Metadata field - download service used"
}, },
"trackMetadataPlay": "Play", "trackMetadataPlay": "Abspielen",
"@trackMetadataPlay": { "@trackMetadataPlay": {
"description": "Action button - play track" "description": "Action button - play track"
}, },
"trackMetadataShare": "Share", "trackMetadataShare": "Teilen",
"@trackMetadataShare": { "@trackMetadataShare": {
"description": "Action button - share track" "description": "Action button - share track"
}, },
"trackMetadataDelete": "Delete", "trackMetadataDelete": "Löschen",
"@trackMetadataDelete": { "@trackMetadataDelete": {
"description": "Action button - delete track" "description": "Action button - delete track"
}, },
"trackMetadataRedownload": "Re-download", "trackMetadataRedownload": "Erneut herunterladen",
"@trackMetadataRedownload": { "@trackMetadataRedownload": {
"description": "Action button - download again" "description": "Action button - download again"
}, },
"trackMetadataOpenFolder": "Open Folder", "trackMetadataOpenFolder": "Ordner öffnen",
"@trackMetadataOpenFolder": { "@trackMetadataOpenFolder": {
"description": "Action button - open containing folder" "description": "Action button - open containing folder"
}, },
"setupTitle": "Welcome to SpotiFLAC", "setupTitle": "Willkommen bei SpotiFLAC",
"@setupTitle": { "@setupTitle": {
"description": "Setup wizard title" "description": "Setup wizard title"
}, },
"setupSubtitle": "Let's get you started", "setupSubtitle": "Los geht's",
"@setupSubtitle": { "@setupSubtitle": {
"description": "Setup wizard subtitle" "description": "Setup wizard subtitle"
}, },
"setupStoragePermission": "Storage Permission", "setupStoragePermission": "Speicherberechtigung",
"@setupStoragePermission": { "@setupStoragePermission": {
"description": "Storage permission step title" "description": "Storage permission step title"
}, },
"setupStoragePermissionSubtitle": "Required to save downloaded files", "setupStoragePermissionSubtitle": "Benötigt um heruntergeladene Dateien zu Speichern",
"@setupStoragePermissionSubtitle": { "@setupStoragePermissionSubtitle": {
"description": "Explanation for storage permission" "description": "Explanation for storage permission"
}, },
"setupStoragePermissionGranted": "Permission granted", "setupStoragePermissionGranted": "Berechtigung erteilt",
"@setupStoragePermissionGranted": { "@setupStoragePermissionGranted": {
"description": "Status when permission granted" "description": "Status when permission granted"
}, },
"setupStoragePermissionDenied": "Permission denied", "setupStoragePermissionDenied": "Berechtigung verweigert",
"@setupStoragePermissionDenied": { "@setupStoragePermissionDenied": {
"description": "Status when permission denied" "description": "Status when permission denied"
}, },
"setupGrantPermission": "Grant Permission", "setupGrantPermission": "Berechtigung erlauben",
"@setupGrantPermission": { "@setupGrantPermission": {
"description": "Button to request permission" "description": "Button to request permission"
}, },
"setupDownloadLocation": "Download Location", "setupDownloadLocation": "Speicherort",
"@setupDownloadLocation": { "@setupDownloadLocation": {
"description": "Download folder step title" "description": "Download folder step title"
}, },
"setupChooseFolder": "Choose Folder", "setupChooseFolder": "Ordner wählen",
"@setupChooseFolder": { "@setupChooseFolder": {
"description": "Button to pick folder" "description": "Button to pick folder"
}, },
"setupContinue": "Continue", "setupContinue": "Fortfahren",
"@setupContinue": { "@setupContinue": {
"description": "Continue to next step button" "description": "Continue to next step button"
}, },
"setupSkip": "Skip for now", "setupSkip": "Vorerst überspringen",
"@setupSkip": { "@setupSkip": {
"description": "Skip current step button" "description": "Skip current step button"
}, },
"setupStorageAccessRequired": "Storage Access Required", "setupStorageAccessRequired": "Speicherzugriff erforderlich",
"@setupStorageAccessRequired": { "@setupStorageAccessRequired": {
"description": "Title when storage access needed" "description": "Title when storage access needed"
}, },
"setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", "setupStorageAccessMessage": "SpotiFLAC benötigt die Berechtigung \"Auf alle Dateien zugreifen\", um Musikdateien in deinen gewählten Ordner zu speichern.",
"@setupStorageAccessMessage": { "@setupStorageAccessMessage": {
"description": "Explanation for storage access" "description": "Explanation for storage access"
}, },
"setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "setupStorageAccessMessageAndroid11": "Android 11+ benötigt die Berechtigung „Auf alle Dateien“, um Dateien im ausgewählten Download-Ordner zu speichern.",
"@setupStorageAccessMessageAndroid11": { "@setupStorageAccessMessageAndroid11": {
"description": "Android 11+ specific explanation" "description": "Android 11+ specific explanation"
}, },
"setupOpenSettings": "Open Settings", "setupOpenSettings": "Einstellungen öffnen",
"@setupOpenSettings": { "@setupOpenSettings": {
"description": "Button to open system settings" "description": "Button to open system settings"
}, },
"setupPermissionDeniedMessage": "Permission denied. Please grant all permissions to continue.", "setupPermissionDeniedMessage": "Berechtigung verweigert. Bitte erteilen Sie alle Berechtigungen um fortzufahren.",
"@setupPermissionDeniedMessage": { "@setupPermissionDeniedMessage": {
"description": "Error when permission denied" "description": "Error when permission denied"
}, },
"setupPermissionRequired": "{permissionType} Permission Required", "setupPermissionRequired": "{permissionType} Zugriff verweigert",
"@setupPermissionRequired": { "@setupPermissionRequired": {
"description": "Generic permission required title", "description": "Generic permission required title",
"placeholders": { "placeholders": {
@@ -782,7 +810,7 @@
} }
} }
}, },
"setupPermissionRequiredMessage": "{permissionType} permission is required for the best experience. You can change this later in Settings.", "setupPermissionRequiredMessage": "{permissionType} Berechtigung ist erforderlich für\ndie beste Benutzererfahrung. Für kannst dies später in den Einstellungen ändern.",
"@setupPermissionRequiredMessage": { "@setupPermissionRequiredMessage": {
"description": "Generic permission required message", "description": "Generic permission required message",
"placeholders": { "placeholders": {
@@ -791,63 +819,63 @@
} }
} }
}, },
"setupSelectDownloadFolder": "Select Download Folder", "setupSelectDownloadFolder": "Wähle Download-Ordner aus",
"@setupSelectDownloadFolder": { "@setupSelectDownloadFolder": {
"description": "Folder selection step title" "description": "Folder selection step title"
}, },
"setupUseDefaultFolder": "Use Default Folder?", "setupUseDefaultFolder": "Als Standardordner verwenden?",
"@setupUseDefaultFolder": { "@setupUseDefaultFolder": {
"description": "Dialog title for default folder" "description": "Dialog title for default folder"
}, },
"setupNoFolderSelected": "No folder selected. Would you like to use the default Music folder?", "setupNoFolderSelected": "Kein Ordner ausgewählt. Soll der Standard-Musikordner verwendet werden?",
"@setupNoFolderSelected": { "@setupNoFolderSelected": {
"description": "Prompt when no folder selected" "description": "Prompt when no folder selected"
}, },
"setupUseDefault": "Use Default", "setupUseDefault": "Standart benutzen",
"@setupUseDefault": { "@setupUseDefault": {
"description": "Button to use default folder" "description": "Button to use default folder"
}, },
"setupDownloadLocationTitle": "Download Location", "setupDownloadLocationTitle": "Speicherort",
"@setupDownloadLocationTitle": { "@setupDownloadLocationTitle": {
"description": "Download location dialog title" "description": "Download location dialog title"
}, },
"setupDownloadLocationIosMessage": "On iOS, downloads are saved to the app's Documents folder. You can access them via the Files app.", "setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Sie können sie über die Datei-App aufrufen.",
"@setupDownloadLocationIosMessage": { "@setupDownloadLocationIosMessage": {
"description": "iOS-specific folder info" "description": "iOS-specific folder info"
}, },
"setupAppDocumentsFolder": "App Documents Folder", "setupAppDocumentsFolder": "App-Dokumentenordner",
"@setupAppDocumentsFolder": { "@setupAppDocumentsFolder": {
"description": "iOS documents folder option" "description": "iOS documents folder option"
}, },
"setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app", "setupAppDocumentsFolderSubtitle": "Empfohlen - zugänglich über die Datei-App",
"@setupAppDocumentsFolderSubtitle": { "@setupAppDocumentsFolderSubtitle": {
"description": "Subtitle for documents folder" "description": "Subtitle for documents folder"
}, },
"setupChooseFromFiles": "Choose from Files", "setupChooseFromFiles": "Aus Dateien auswählen",
"@setupChooseFromFiles": { "@setupChooseFromFiles": {
"description": "iOS file picker option" "description": "iOS file picker option"
}, },
"setupChooseFromFilesSubtitle": "Select iCloud or other location", "setupChooseFromFilesSubtitle": "Wählen Sie iCloud oder einen anderen Ort",
"@setupChooseFromFilesSubtitle": { "@setupChooseFromFilesSubtitle": {
"description": "Subtitle for file picker" "description": "Subtitle for file picker"
}, },
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", "setupIosEmptyFolderWarning": "iOS-Einschränkung: Leere Ordner können nicht ausgewählt werden. Wählen Sie einen Ordner mit mindestens einer Datei.",
"@setupIosEmptyFolderWarning": { "@setupIosEmptyFolderWarning": {
"description": "iOS folder selection warning" "description": "iOS folder selection warning"
}, },
"setupDownloadInFlac": "Download Spotify tracks in FLAC", "setupDownloadInFlac": "Spotify Titel in FLAC herunterladen",
"@setupDownloadInFlac": { "@setupDownloadInFlac": {
"description": "App tagline in setup" "description": "App tagline in setup"
}, },
"setupStepStorage": "Storage", "setupStepStorage": "Speicherort",
"@setupStepStorage": { "@setupStepStorage": {
"description": "Setup step indicator - storage" "description": "Setup step indicator - storage"
}, },
"setupStepNotification": "Notification", "setupStepNotification": "Benachrichtigung",
"@setupStepNotification": { "@setupStepNotification": {
"description": "Setup step indicator - notification" "description": "Setup step indicator - notification"
}, },
"setupStepFolder": "Folder", "setupStepFolder": "Ordner",
"@setupStepFolder": { "@setupStepFolder": {
"description": "Setup step indicator - folder" "description": "Setup step indicator - folder"
}, },
@@ -855,55 +883,55 @@
"@setupStepSpotify": { "@setupStepSpotify": {
"description": "Setup step indicator - Spotify API" "description": "Setup step indicator - Spotify API"
}, },
"setupStepPermission": "Permission", "setupStepPermission": "Berechtigung",
"@setupStepPermission": { "@setupStepPermission": {
"description": "Setup step indicator - permission" "description": "Setup step indicator - permission"
}, },
"setupStorageGranted": "Storage Permission Granted!", "setupStorageGranted": "Speicherberechtigung erlaubt!",
"@setupStorageGranted": { "@setupStorageGranted": {
"description": "Success message for storage permission" "description": "Success message for storage permission"
}, },
"setupStorageRequired": "Storage Permission Required", "setupStorageRequired": "Speicherzugriff erforderlich",
"@setupStorageRequired": { "@setupStorageRequired": {
"description": "Title when storage permission needed" "description": "Title when storage permission needed"
}, },
"setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.", "setupStorageDescription": "SpotiFLAC benötigt Speicherrechte, um die heruntergeladenen Musikdateien zu speichern.",
"@setupStorageDescription": { "@setupStorageDescription": {
"description": "Explanation for storage permission" "description": "Explanation for storage permission"
}, },
"setupNotificationGranted": "Notification Permission Granted!", "setupNotificationGranted": "Benachrichtigungs-Berechtigung erteilt",
"@setupNotificationGranted": { "@setupNotificationGranted": {
"description": "Success message for notification permission" "description": "Success message for notification permission"
}, },
"setupNotificationEnable": "Enable Notifications", "setupNotificationEnable": "Benachrichtigungen aktivieren",
"@setupNotificationEnable": { "@setupNotificationEnable": {
"description": "Button to enable notifications" "description": "Button to enable notifications"
}, },
"setupNotificationDescription": "Get notified when downloads complete or require attention.", "setupNotificationDescription": "Benachrichtigt werden, wenn Downloads abgeschlossen sind.",
"@setupNotificationDescription": { "@setupNotificationDescription": {
"description": "Explanation for notifications" "description": "Explanation for notifications"
}, },
"setupFolderSelected": "Download Folder Selected!", "setupFolderSelected": "Download Ordner ausgewählt!",
"@setupFolderSelected": { "@setupFolderSelected": {
"description": "Success message for folder selection" "description": "Success message for folder selection"
}, },
"setupFolderChoose": "Choose Download Folder", "setupFolderChoose": "Speicherort auwählen",
"@setupFolderChoose": { "@setupFolderChoose": {
"description": "Button to choose folder" "description": "Button to choose folder"
}, },
"setupFolderDescription": "Select a folder where your downloaded music will be saved.", "setupFolderDescription": "Wählen Sie einen Ordner, in dem Ihre heruntergeladene Musik gespeichert wird.",
"@setupFolderDescription": { "@setupFolderDescription": {
"description": "Explanation for folder selection" "description": "Explanation for folder selection"
}, },
"setupChangeFolder": "Change Folder", "setupChangeFolder": "Ordner ändern",
"@setupChangeFolder": { "@setupChangeFolder": {
"description": "Button to change selected folder" "description": "Button to change selected folder"
}, },
"setupSelectFolder": "Select Folder", "setupSelectFolder": "Ordner wählen",
"@setupSelectFolder": { "@setupSelectFolder": {
"description": "Button to select folder" "description": "Button to select folder"
}, },
"setupSpotifyApiOptional": "Spotify API (Optional)", "setupSpotifyApiOptional": "Spotify-API (optional)",
"@setupSpotifyApiOptional": { "@setupSpotifyApiOptional": {
"description": "Spotify API step title" "description": "Spotify API step title"
}, },
@@ -1122,6 +1150,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1888,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2070,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2102,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2633,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2697,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2746,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+64 -13
View File
@@ -334,6 +334,8 @@
"@aboutBinimumDesc": {"description": "Credit description for binimum"}, "@aboutBinimumDesc": {"description": "Credit description for binimum"},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", "aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"@aboutSachinsenalDesc": {"description": "Credit description for sachinsenal0x64"}, "@aboutSachinsenalDesc": {"description": "Credit description for sachinsenal0x64"},
"aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!",
"@aboutSjdonadoDesc": {"description": "Credit description for sjdonado"},
"aboutDoubleDouble": "DoubleDouble", "aboutDoubleDouble": "DoubleDouble",
"@aboutDoubleDouble": {"description": "Name of Amazon API service - DO NOT TRANSLATE"}, "@aboutDoubleDouble": {"description": "Name of Amazon API service - DO NOT TRANSLATE"},
"aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!",
@@ -479,8 +481,10 @@
"@setupChooseFromFiles": {"description": "iOS file picker option"}, "@setupChooseFromFiles": {"description": "iOS file picker option"},
"setupChooseFromFilesSubtitle": "Select iCloud or other location", "setupChooseFromFilesSubtitle": "Select iCloud or other location",
"@setupChooseFromFilesSubtitle": {"description": "Subtitle for file picker"}, "@setupChooseFromFilesSubtitle": {"description": "Subtitle for file picker"},
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", "setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
"@setupIosEmptyFolderWarning": {"description": "iOS folder selection warning"}, "@setupIosEmptyFolderWarning": {"description": "iOS folder selection warning"},
"setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.",
"@setupIcloudNotSupported": {"description": "Error when user selects iCloud Drive on iOS"},
"setupDownloadInFlac": "Download Spotify tracks in FLAC", "setupDownloadInFlac": "Download Spotify tracks in FLAC",
"@setupDownloadInFlac": {"description": "App tagline in setup"}, "@setupDownloadInFlac": {"description": "App tagline in setup"},
"setupStepStorage": "Storage", "setupStepStorage": "Storage",
@@ -1188,6 +1192,12 @@
"@trackLyricsTimeout": {"description": "Message when lyrics request times out"}, "@trackLyricsTimeout": {"description": "Message when lyrics request times out"},
"trackLyricsLoadFailed": "Failed to load lyrics", "trackLyricsLoadFailed": "Failed to load lyrics",
"@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"}, "@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"},
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {"description": "Action - embed lyrics into audio file"},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {"description": "Snackbar - lyrics saved to file"},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {"description": "Message when track is instrumental (no lyrics)"},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": {"description": "Snackbar - content copied"}, "@trackCopiedToClipboard": {"description": "Snackbar - content copied"},
"trackDeleteConfirmTitle": "Remove from device?", "trackDeleteConfirmTitle": "Remove from device?",
@@ -1367,16 +1377,26 @@
"@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"}, "@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"},
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz", "qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
"@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"}, "@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"},
"qualityMp3": "MP3", "qualityLossy": "Lossy",
"@qualityMp3": {"description": "Quality option - MP3 lossy format"}, "@qualityLossy": {"description": "Quality option - lossy format (MP3/Opus)"},
"qualityMp3Subtitle": "320kbps (converted from FLAC)", "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {"description": "Technical spec for MP3"}, "@qualityLossyMp3Subtitle": {"description": "Technical spec for lossy MP3"},
"enableMp3Option": "Enable MP3 Option", "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)",
"@enableMp3Option": {"description": "Setting - enable MP3 quality option"}, "@qualityLossyOpusSubtitle": {"description": "Technical spec for lossy Opus"},
"enableMp3OptionSubtitleOn": "MP3 quality option is available", "enableLossyOption": "Enable Lossy Option",
"@enableMp3OptionSubtitleOn": {"description": "Subtitle when MP3 is enabled"}, "@enableLossyOption": {"description": "Setting - enable lossy quality option"},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3", "enableLossyOptionSubtitleOn": "Lossy quality option is available",
"@enableMp3OptionSubtitleOff": {"description": "Subtitle when MP3 is disabled"}, "@enableLossyOptionSubtitleOn": {"description": "Subtitle when lossy is enabled"},
"enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format",
"@enableLossyOptionSubtitleOff": {"description": "Subtitle when lossy is disabled"},
"lossyFormat": "Lossy Format",
"@lossyFormat": {"description": "Setting - choose lossy format"},
"lossyFormatDescription": "Choose the lossy format for conversion",
"@lossyFormatDescription": {"description": "Description for lossy format picker"},
"lossyFormatMp3Subtitle": "320kbps, best compatibility",
"@lossyFormatMp3Subtitle": {"description": "MP3 format description"},
"lossyFormatOpusSubtitle": "128kbps, better quality at smaller size",
"@lossyFormatOpusSubtitle": {"description": "Opus format description"},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {"description": "Note about quality availability"}, "@qualityNote": {"description": "Note about quality availability"},
@@ -1440,10 +1460,22 @@
"queueTitle": "Download Queue", "queueTitle": "Download Queue",
"@queueTitle": {"description": "Queue screen title"}, "@queueTitle": {"description": "Queue screen title"},
"queueClearAll": "Clear All", "queueClearAll": "Clear All",
"@queueClearAll": {"description": "Button - clear all queue items"}, "@queueClearAll": {"description": "Button - clear all queue items"},
"queueClearAllMessage": "Are you sure you want to clear all downloads?", "queueClearAllMessage": "Are you sure you want to clear all downloads?",
"@queueClearAllMessage": {"description": "Clear queue confirmation"}, "@queueClearAllMessage": {"description": "Clear queue confirmation"},
"queueExportFailed": "Export",
"@queueExportFailed": {"description": "Button - export failed downloads to TXT"},
"queueExportFailedSuccess": "Failed downloads exported to TXT file",
"@queueExportFailedSuccess": {"description": "Success message after exporting failed downloads"},
"queueExportFailedClear": "Clear Failed",
"@queueExportFailedClear": {"description": "Action to clear failed downloads after export"},
"queueExportFailedError": "Failed to export downloads",
"@queueExportFailedError": {"description": "Error message when export fails"},
"settingsAutoExportFailed": "Auto-export failed downloads",
"@settingsAutoExportFailed": {"description": "Setting toggle for auto-export"},
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
"@settingsAutoExportFailedSubtitle": {"description": "Subtitle for auto-export setting"},
"queueEmpty": "No downloads in queue", "queueEmpty": "No downloads in queue",
"@queueEmpty": {"description": "Empty queue state title"}, "@queueEmpty": {"description": "Empty queue state title"},
"queueEmptySubtitle": "Add tracks from the home screen", "queueEmptySubtitle": "Add tracks from the home screen",
@@ -1477,6 +1509,10 @@
"@albumFolderYearAlbum": {"description": "Album folder option with year"}, "@albumFolderYearAlbum": {"description": "Album folder option with year"},
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", "albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
"@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"}, "@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"},
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {"description": "Album folder option with singles inside artist"},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {"description": "Folder structure example"},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"}, "@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"},
@@ -1624,5 +1660,20 @@
"discographyNoAlbums": "No albums available", "discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {"description": "Error - no albums found for artist"}, "@discographyNoAlbums": {"description": "Error - no albums found for artist"},
"discographyFailedToFetch": "Failed to fetch some albums", "discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {"description": "Error - some albums failed to load"} "@discographyFailedToFetch": {"description": "Error - some albums failed to load"},
"sectionStorageAccess": "Storage Access",
"@sectionStorageAccess": {"description": "Section header for storage access settings"},
"allFilesAccess": "All Files Access",
"@allFilesAccess": {"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"},
"allFilesAccessEnabledSubtitle": "Can write to any folder",
"@allFilesAccessEnabledSubtitle": {"description": "Subtitle when all files access is enabled"},
"allFilesAccessDisabledSubtitle": "Limited to media folders only",
"@allFilesAccessDisabledSubtitle": {"description": "Subtitle when all files access is disabled"},
"allFilesAccessDescription": "Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.",
"@allFilesAccessDescription": {"description": "Description explaining when to enable all files access"},
"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"}
} }
+253
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
@@ -1122,6 +1150,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1888,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2070,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2102,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2633,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2697,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2746,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+261 -8
View File
@@ -1,23 +1,23 @@
{ {
"@@locale": "hi", "@@locale": "hi",
"@@last_modified": "2026-01-16", "@@last_modified": "2026-01-16",
"appName": "SpotiFLAC", "appName": "SpotiFlac",
"@appName": { "@appName": {
"description": "App name - DO NOT TRANSLATE" "description": "App name - DO NOT TRANSLATE"
}, },
"appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", "appDescription": "स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।",
"@appDescription": { "@appDescription": {
"description": "App description shown in about page" "description": "App description shown in about page"
}, },
"navHome": "Home", "navHome": "होम",
"@navHome": { "@navHome": {
"description": "Bottom navigation - Home tab" "description": "Bottom navigation - Home tab"
}, },
"navHistory": "History", "navHistory": "इतिहास",
"@navHistory": { "@navHistory": {
"description": "Bottom navigation - History tab" "description": "Bottom navigation - History tab"
}, },
"navSettings": "Settings", "navSettings": "विकल्प",
"@navSettings": { "@navSettings": {
"description": "Bottom navigation - Settings tab" "description": "Bottom navigation - Settings tab"
}, },
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -219,7 +223,7 @@
"@quality128": { "@quality128": {
"description": "Audio quality option - 128kbps MP3" "description": "Audio quality option - 128kbps MP3"
}, },
"appearanceTitle": "Appearance", "appearanceTitle": "दिखावट",
"@appearanceTitle": { "@appearanceTitle": {
"description": "Appearance settings page title" "description": "Appearance settings page title"
}, },
@@ -239,11 +243,11 @@
"@appearanceThemeDark": { "@appearanceThemeDark": {
"description": "Dark theme" "description": "Dark theme"
}, },
"appearanceDynamicColor": "Dynamic Color", "appearanceDynamicColor": "डायनेमिक रंग",
"@appearanceDynamicColor": { "@appearanceDynamicColor": {
"description": "Material You dynamic colors" "description": "Material You dynamic colors"
}, },
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper", "appearanceDynamicColorSubtitle": "वॉलपेपर से रंग इस्तेमाल करें",
"@appearanceDynamicColorSubtitle": { "@appearanceDynamicColorSubtitle": {
"description": "Subtitle for dynamic color" "description": "Subtitle for dynamic color"
}, },
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
@@ -1122,6 +1150,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1888,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2070,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2102,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2633,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2697,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2746,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+2727 -564
View File
File diff suppressed because it is too large Load Diff
+634 -381
View File
File diff suppressed because it is too large Load Diff
+253
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
@@ -1122,6 +1150,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1888,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2070,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2102,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2633,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2697,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2746,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+253
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
@@ -1122,6 +1150,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1888,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2070,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2102,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2633,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2697,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2746,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+141 -141
View File
@@ -835,19 +835,19 @@
"@setupIosEmptyFolderWarning": { "@setupIosEmptyFolderWarning": {
"description": "iOS folder selection warning" "description": "iOS folder selection warning"
}, },
"setupDownloadInFlac": "Download Spotify tracks in FLAC", "setupDownloadInFlac": "Baixe faixas do Spotify em FLAC",
"@setupDownloadInFlac": { "@setupDownloadInFlac": {
"description": "App tagline in setup" "description": "App tagline in setup"
}, },
"setupStepStorage": "Storage", "setupStepStorage": "Armazenamento",
"@setupStepStorage": { "@setupStepStorage": {
"description": "Setup step indicator - storage" "description": "Setup step indicator - storage"
}, },
"setupStepNotification": "Notification", "setupStepNotification": "Notificação",
"@setupStepNotification": { "@setupStepNotification": {
"description": "Setup step indicator - notification" "description": "Setup step indicator - notification"
}, },
"setupStepFolder": "Folder", "setupStepFolder": "Pasta",
"@setupStepFolder": { "@setupStepFolder": {
"description": "Setup step indicator - folder" "description": "Setup step indicator - folder"
}, },
@@ -855,19 +855,19 @@
"@setupStepSpotify": { "@setupStepSpotify": {
"description": "Setup step indicator - Spotify API" "description": "Setup step indicator - Spotify API"
}, },
"setupStepPermission": "Permission", "setupStepPermission": "Permissão",
"@setupStepPermission": { "@setupStepPermission": {
"description": "Setup step indicator - permission" "description": "Setup step indicator - permission"
}, },
"setupStorageGranted": "Storage Permission Granted!", "setupStorageGranted": "Permissão de Armazenamento Concedida!",
"@setupStorageGranted": { "@setupStorageGranted": {
"description": "Success message for storage permission" "description": "Success message for storage permission"
}, },
"setupStorageRequired": "Storage Permission Required", "setupStorageRequired": "Permissão de Armazenamento Necessária",
"@setupStorageRequired": { "@setupStorageRequired": {
"description": "Title when storage permission needed" "description": "Title when storage permission needed"
}, },
"setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.", "setupStorageDescription": "O SpotiFLAC precisa de permissão de armazenamento para salvar os seus arquivos de música baixados.",
"@setupStorageDescription": { "@setupStorageDescription": {
"description": "Explanation for storage permission" "description": "Explanation for storage permission"
}, },
@@ -1071,23 +1071,23 @@
"@dialogClearAllDownloads": { "@dialogClearAllDownloads": {
"description": "Dialog message - clear downloads confirmation" "description": "Dialog message - clear downloads confirmation"
}, },
"dialogRemoveFromDevice": "Remove from device?", "dialogRemoveFromDevice": "Remover do dispositivo?",
"@dialogRemoveFromDevice": { "@dialogRemoveFromDevice": {
"description": "Dialog title - delete file confirmation" "description": "Dialog title - delete file confirmation"
}, },
"dialogRemoveExtension": "Remove Extension", "dialogRemoveExtension": "Remover Extensão",
"@dialogRemoveExtension": { "@dialogRemoveExtension": {
"description": "Dialog title - uninstall extension" "description": "Dialog title - uninstall extension"
}, },
"dialogRemoveExtensionMessage": "Are you sure you want to remove this extension? This cannot be undone.", "dialogRemoveExtensionMessage": "Tem certeza de que deseja remover esta extensão? Isso não pode ser desfeito.",
"@dialogRemoveExtensionMessage": { "@dialogRemoveExtensionMessage": {
"description": "Dialog message - uninstall confirmation" "description": "Dialog message - uninstall confirmation"
}, },
"dialogUninstallExtension": "Uninstall Extension?", "dialogUninstallExtension": "Desinstalar Extensão?",
"@dialogUninstallExtension": { "@dialogUninstallExtension": {
"description": "Dialog title - uninstall extension" "description": "Dialog title - uninstall extension"
}, },
"dialogUninstallExtensionMessage": "Are you sure you want to remove {extensionName}?", "dialogUninstallExtensionMessage": "Tem certeza de que deseja remover {extensionName}?",
"@dialogUninstallExtensionMessage": { "@dialogUninstallExtensionMessage": {
"description": "Dialog message - uninstall specific extension", "description": "Dialog message - uninstall specific extension",
"placeholders": { "placeholders": {
@@ -1096,19 +1096,19 @@
} }
} }
}, },
"dialogClearHistoryTitle": "Clear History", "dialogClearHistoryTitle": "Limpar Histórico",
"@dialogClearHistoryTitle": { "@dialogClearHistoryTitle": {
"description": "Dialog title - clear download history" "description": "Dialog title - clear download history"
}, },
"dialogClearHistoryMessage": "Are you sure you want to clear all download history? This cannot be undone.", "dialogClearHistoryMessage": "Tem certeza de que deseja limpar todo o histórico de downloads? Isso não pode ser desfeito.",
"@dialogClearHistoryMessage": { "@dialogClearHistoryMessage": {
"description": "Dialog message - clear history confirmation" "description": "Dialog message - clear history confirmation"
}, },
"dialogDeleteSelectedTitle": "Delete Selected", "dialogDeleteSelectedTitle": "Apagar Selecionados",
"@dialogDeleteSelectedTitle": { "@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items" "description": "Dialog title - delete selected items"
}, },
"dialogDeleteSelectedMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from history?\n\nThis will also delete the files from storage.", "dialogDeleteSelectedMessage": "Apagar {count} {count, plural, =1{faixa} other{faixas}} do histórico?\n\nIsso também apagará os arquivos do armazenamento.",
"@dialogDeleteSelectedMessage": { "@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks", "description": "Dialog message - delete selected tracks",
"placeholders": { "placeholders": {
@@ -1117,11 +1117,11 @@
} }
} }
}, },
"dialogImportPlaylistTitle": "Import Playlist", "dialogImportPlaylistTitle": "Importar Playlist",
"@dialogImportPlaylistTitle": { "@dialogImportPlaylistTitle": {
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Encontradas {count} faixas no CSV. Adicionar à fila de download?",
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1130,7 +1130,7 @@
} }
} }
}, },
"snackbarAddedToQueue": "Added \"{trackName}\" to queue", "snackbarAddedToQueue": "\"{trackName}\" adicionada à fila",
"@snackbarAddedToQueue": { "@snackbarAddedToQueue": {
"description": "Snackbar - track added to download queue", "description": "Snackbar - track added to download queue",
"placeholders": { "placeholders": {
@@ -1139,7 +1139,7 @@
} }
} }
}, },
"snackbarAddedTracksToQueue": "Added {count} tracks to queue", "snackbarAddedTracksToQueue": "{count} faixas adicionadas à fila",
"@snackbarAddedTracksToQueue": { "@snackbarAddedTracksToQueue": {
"description": "Snackbar - multiple tracks added to queue", "description": "Snackbar - multiple tracks added to queue",
"placeholders": { "placeholders": {
@@ -1148,7 +1148,7 @@
} }
} }
}, },
"snackbarAlreadyDownloaded": "\"{trackName}\" already downloaded", "snackbarAlreadyDownloaded": "\"{trackName}\" já foi baixada",
"@snackbarAlreadyDownloaded": { "@snackbarAlreadyDownloaded": {
"description": "Snackbar - track already exists", "description": "Snackbar - track already exists",
"placeholders": { "placeholders": {
@@ -1157,19 +1157,19 @@
} }
} }
}, },
"snackbarHistoryCleared": "History cleared", "snackbarHistoryCleared": "Histórico limpo",
"@snackbarHistoryCleared": { "@snackbarHistoryCleared": {
"description": "Snackbar - history deleted" "description": "Snackbar - history deleted"
}, },
"snackbarCredentialsSaved": "Credentials saved", "snackbarCredentialsSaved": "Credenciais salvas",
"@snackbarCredentialsSaved": { "@snackbarCredentialsSaved": {
"description": "Snackbar - Spotify credentials saved" "description": "Snackbar - Spotify credentials saved"
}, },
"snackbarCredentialsCleared": "Credentials cleared", "snackbarCredentialsCleared": "Credenciais removidas",
"@snackbarCredentialsCleared": { "@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed" "description": "Snackbar - Spotify credentials removed"
}, },
"snackbarDeletedTracks": "Deleted {count} {count, plural, =1{track} other{tracks}}", "snackbarDeletedTracks": "{count} {count, plural, =1{faixa apagada} other{faixas apagadas}}",
"@snackbarDeletedTracks": { "@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted", "description": "Snackbar - tracks deleted",
"placeholders": { "placeholders": {
@@ -1178,7 +1178,7 @@
} }
} }
}, },
"snackbarCannotOpenFile": "Cannot open file: {error}", "snackbarCannotOpenFile": "Não foi possível abrir o arquivo: {error}",
"@snackbarCannotOpenFile": { "@snackbarCannotOpenFile": {
"description": "Snackbar - file open error", "description": "Snackbar - file open error",
"placeholders": { "placeholders": {
@@ -1187,15 +1187,15 @@
} }
} }
}, },
"snackbarFillAllFields": "Please fill all fields", "snackbarFillAllFields": "Por favor, preencha todos os campos",
"@snackbarFillAllFields": { "@snackbarFillAllFields": {
"description": "Snackbar - validation error" "description": "Snackbar - validation error"
}, },
"snackbarViewQueue": "View Queue", "snackbarViewQueue": "Ver Fila",
"@snackbarViewQueue": { "@snackbarViewQueue": {
"description": "Snackbar action - view download queue" "description": "Snackbar action - view download queue"
}, },
"snackbarFailedToLoad": "Failed to load: {error}", "snackbarFailedToLoad": "Falha ao carregar: {error}",
"@snackbarFailedToLoad": { "@snackbarFailedToLoad": {
"description": "Snackbar - loading error", "description": "Snackbar - loading error",
"placeholders": { "placeholders": {
@@ -1204,7 +1204,7 @@
} }
} }
}, },
"snackbarUrlCopied": "{platform} URL copied to clipboard", "snackbarUrlCopied": "URL do {platform} copiada para a área de transferência",
"@snackbarUrlCopied": { "@snackbarUrlCopied": {
"description": "Snackbar - URL copied", "description": "Snackbar - URL copied",
"placeholders": { "placeholders": {
@@ -1214,23 +1214,23 @@
} }
} }
}, },
"snackbarFileNotFound": "File not found", "snackbarFileNotFound": "Arquivo não encontrado",
"@snackbarFileNotFound": { "@snackbarFileNotFound": {
"description": "Snackbar - file doesn't exist" "description": "Snackbar - file doesn't exist"
}, },
"snackbarSelectExtFile": "Please select a .spotiflac-ext file", "snackbarSelectExtFile": "Por favor, selecione um arquivo .spotiflac-ext",
"@snackbarSelectExtFile": { "@snackbarSelectExtFile": {
"description": "Snackbar - wrong file type selected" "description": "Snackbar - wrong file type selected"
}, },
"snackbarProviderPrioritySaved": "Provider priority saved", "snackbarProviderPrioritySaved": "Prioridade de provedor salva",
"@snackbarProviderPrioritySaved": { "@snackbarProviderPrioritySaved": {
"description": "Snackbar - provider order saved" "description": "Snackbar - provider order saved"
}, },
"snackbarMetadataProviderSaved": "Metadata provider priority saved", "snackbarMetadataProviderSaved": "Prioridade de provedor de metadados salva",
"@snackbarMetadataProviderSaved": { "@snackbarMetadataProviderSaved": {
"description": "Snackbar - metadata provider order saved" "description": "Snackbar - metadata provider order saved"
}, },
"snackbarExtensionInstalled": "{extensionName} installed.", "snackbarExtensionInstalled": "{extensionName} instalada.",
"@snackbarExtensionInstalled": { "@snackbarExtensionInstalled": {
"description": "Snackbar - extension installed successfully", "description": "Snackbar - extension installed successfully",
"placeholders": { "placeholders": {
@@ -1239,7 +1239,7 @@
} }
} }
}, },
"snackbarExtensionUpdated": "{extensionName} updated.", "snackbarExtensionUpdated": "{extensionName} atualizada.",
"@snackbarExtensionUpdated": { "@snackbarExtensionUpdated": {
"description": "Snackbar - extension updated successfully", "description": "Snackbar - extension updated successfully",
"placeholders": { "placeholders": {
@@ -1248,23 +1248,23 @@
} }
} }
}, },
"snackbarFailedToInstall": "Failed to install extension", "snackbarFailedToInstall": "Falha ao instalar extensão",
"@snackbarFailedToInstall": { "@snackbarFailedToInstall": {
"description": "Snackbar - extension install error" "description": "Snackbar - extension install error"
}, },
"snackbarFailedToUpdate": "Failed to update extension", "snackbarFailedToUpdate": "Falha ao atualizar extensão",
"@snackbarFailedToUpdate": { "@snackbarFailedToUpdate": {
"description": "Snackbar - extension update error" "description": "Snackbar - extension update error"
}, },
"errorRateLimited": "Rate Limited", "errorRateLimited": "Taxa Limitada",
"@errorRateLimited": { "@errorRateLimited": {
"description": "Error title - too many requests" "description": "Error title - too many requests"
}, },
"errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.", "errorRateLimitedMessage": "Muitas solicitações. Por favor, aguarde um momento antes de pesquisar novamente.",
"@errorRateLimitedMessage": { "@errorRateLimitedMessage": {
"description": "Error message - rate limit explanation" "description": "Error message - rate limit explanation"
}, },
"errorFailedToLoad": "Failed to load {item}", "errorFailedToLoad": "Falha ao carregar {item}",
"@errorFailedToLoad": { "@errorFailedToLoad": {
"description": "Error message - loading failed", "description": "Error message - loading failed",
"placeholders": { "placeholders": {
@@ -1274,11 +1274,11 @@
} }
} }
}, },
"errorNoTracksFound": "No tracks found", "errorNoTracksFound": "Nenhuma faixa encontrada",
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorMissingExtensionSource": "Cannot load {item}: missing extension source", "errorMissingExtensionSource": "Não foi possível carregar {item}: fonte de extensão ausente",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
"placeholders": { "placeholders": {
@@ -1287,23 +1287,23 @@
} }
} }
}, },
"statusQueued": "Queued", "statusQueued": "Na Fila",
"@statusQueued": { "@statusQueued": {
"description": "Download status - waiting in queue" "description": "Download status - waiting in queue"
}, },
"statusDownloading": "Downloading", "statusDownloading": "Baixando",
"@statusDownloading": { "@statusDownloading": {
"description": "Download status - in progress" "description": "Download status - in progress"
}, },
"statusFinalizing": "Finalizing", "statusFinalizing": "Finalizando",
"@statusFinalizing": { "@statusFinalizing": {
"description": "Download status - writing metadata" "description": "Download status - writing metadata"
}, },
"statusCompleted": "Completed", "statusCompleted": "Concluído",
"@statusCompleted": { "@statusCompleted": {
"description": "Download status - finished" "description": "Download status - finished"
}, },
"statusFailed": "Failed", "statusFailed": "Falhou",
"@statusFailed": { "@statusFailed": {
"description": "Download status - error occurred" "description": "Download status - error occurred"
}, },
@@ -1735,19 +1735,19 @@
"@logNetworkErrorDescription": { "@logNetworkErrorDescription": {
"description": "Network error explanation" "description": "Network error explanation"
}, },
"logNetworkErrorSuggestion": "Check your internet connection", "logNetworkErrorSuggestion": "Verifique a sua conexão com a internet",
"@logNetworkErrorSuggestion": { "@logNetworkErrorSuggestion": {
"description": "Network error fix suggestion" "description": "Network error fix suggestion"
}, },
"logTrackNotFoundDescription": "Some tracks could not be found on download services", "logTrackNotFoundDescription": "Algumas faixas não foram encontradas nos serviços de download",
"@logTrackNotFoundDescription": { "@logTrackNotFoundDescription": {
"description": "Track not found explanation" "description": "Track not found explanation"
}, },
"logTrackNotFoundSuggestion": "The track may not be available in lossless quality", "logTrackNotFoundSuggestion": "A faixa pode não estar disponível em qualidade lossless",
"@logTrackNotFoundSuggestion": { "@logTrackNotFoundSuggestion": {
"description": "Track not found explanation" "description": "Track not found explanation"
}, },
"logTotalErrors": "Total errors: {count}", "logTotalErrors": "Total de erros: {count}",
"@logTotalErrors": { "@logTotalErrors": {
"description": "Error count display", "description": "Error count display",
"placeholders": { "placeholders": {
@@ -1756,7 +1756,7 @@
} }
} }
}, },
"logAffected": "Affected: {domains}", "logAffected": "Afetados: {domains}",
"@logAffected": { "@logAffected": {
"description": "Affected domains display", "description": "Affected domains display",
"placeholders": { "placeholders": {
@@ -1765,7 +1765,7 @@
} }
} }
}, },
"logEntriesFiltered": "Entries ({count} filtered)", "logEntriesFiltered": "Entradas ({count} filtradas)",
"@logEntriesFiltered": { "@logEntriesFiltered": {
"description": "Log count with filter active", "description": "Log count with filter active",
"placeholders": { "placeholders": {
@@ -1774,7 +1774,7 @@
} }
} }
}, },
"logEntries": "Entries ({count})", "logEntries": "Entradas ({count})",
"@logEntries": { "@logEntries": {
"description": "Total log count", "description": "Total log count",
"placeholders": { "placeholders": {
@@ -1783,11 +1783,11 @@
} }
} }
}, },
"credentialsTitle": "Spotify Credentials", "credentialsTitle": "Credenciais do Spotify",
"@credentialsTitle": { "@credentialsTitle": {
"description": "Credentials dialog title" "description": "Credentials dialog title"
}, },
"credentialsDescription": "Enter your Client ID and Secret to use your own Spotify application quota.", "credentialsDescription": "Insira o seu Client ID e Secret para usar a sua própria cota de aplicativo do Spotify.",
"@credentialsDescription": { "@credentialsDescription": {
"description": "Credentials dialog explanation" "description": "Credentials dialog explanation"
}, },
@@ -2001,35 +2001,35 @@
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
}, },
"trackCopyLyrics": "Copy lyrics", "trackCopyLyrics": "Copiar letras",
"@trackCopyLyrics": { "@trackCopyLyrics": {
"description": "Action - copy lyrics to clipboard" "description": "Action - copy lyrics to clipboard"
}, },
"trackLyricsNotAvailable": "Lyrics not available for this track", "trackLyricsNotAvailable": "Letras não disponíveis para esta faixa",
"@trackLyricsNotAvailable": { "@trackLyricsNotAvailable": {
"description": "Message when lyrics not found" "description": "Message when lyrics not found"
}, },
"trackLyricsTimeout": "Request timed out. Try again later.", "trackLyricsTimeout": "A solicitação expirou. Tente novamente mais tarde.",
"@trackLyricsTimeout": { "@trackLyricsTimeout": {
"description": "Message when lyrics request times out" "description": "Message when lyrics request times out"
}, },
"trackLyricsLoadFailed": "Failed to load lyrics", "trackLyricsLoadFailed": "Falha ao carregar letras",
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copiado para a área de transferência",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
}, },
"trackDeleteConfirmTitle": "Remove from device?", "trackDeleteConfirmTitle": "Remover do dispositivo?",
"@trackDeleteConfirmTitle": { "@trackDeleteConfirmTitle": {
"description": "Delete confirmation title" "description": "Delete confirmation title"
}, },
"trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.", "trackDeleteConfirmMessage": "Isso apagará permanentemente o arquivo baixado e o removerá do seu histórico.",
"@trackDeleteConfirmMessage": { "@trackDeleteConfirmMessage": {
"description": "Delete confirmation message" "description": "Delete confirmation message"
}, },
"trackCannotOpen": "Cannot open: {message}", "trackCannotOpen": "Não foi possível abrir: {message}",
"@trackCannotOpen": { "@trackCannotOpen": {
"description": "Error opening file", "description": "Error opening file",
"placeholders": { "placeholders": {
@@ -2038,15 +2038,15 @@
} }
} }
}, },
"dateToday": "Today", "dateToday": "Hoje",
"@dateToday": { "@dateToday": {
"description": "Relative date - today" "description": "Relative date - today"
}, },
"dateYesterday": "Yesterday", "dateYesterday": "Ontem",
"@dateYesterday": { "@dateYesterday": {
"description": "Relative date - yesterday" "description": "Relative date - yesterday"
}, },
"dateDaysAgo": "{count} days ago", "dateDaysAgo": "{count} dias",
"@dateDaysAgo": { "@dateDaysAgo": {
"description": "Relative date - days ago", "description": "Relative date - days ago",
"placeholders": { "placeholders": {
@@ -2055,7 +2055,7 @@
} }
} }
}, },
"dateWeeksAgo": "{count} weeks ago", "dateWeeksAgo": "{count} semanas",
"@dateWeeksAgo": { "@dateWeeksAgo": {
"description": "Relative date - weeks ago", "description": "Relative date - weeks ago",
"placeholders": { "placeholders": {
@@ -2064,7 +2064,7 @@
} }
} }
}, },
"dateMonthsAgo": "{count} months ago", "dateMonthsAgo": "{count} meses",
"@dateMonthsAgo": { "@dateMonthsAgo": {
"description": "Relative date - months ago", "description": "Relative date - months ago",
"placeholders": { "placeholders": {
@@ -2073,27 +2073,27 @@
} }
} }
}, },
"concurrentSequential": "Sequential", "concurrentSequential": "Sequencial",
"@concurrentSequential": { "@concurrentSequential": {
"description": "Download mode - one at a time" "description": "Download mode - one at a time"
}, },
"concurrentParallel2": "2 Parallel", "concurrentParallel2": "2 Paralelos",
"@concurrentParallel2": { "@concurrentParallel2": {
"description": "Download mode - 2 simultaneous" "description": "Download mode - 2 simultaneous"
}, },
"concurrentParallel3": "3 Parallel", "concurrentParallel3": "3 Paralelos",
"@concurrentParallel3": { "@concurrentParallel3": {
"description": "Download mode - 3 simultaneous" "description": "Download mode - 3 simultaneous"
}, },
"tapToSeeError": "Tap to see error details", "tapToSeeError": "Toque para ver detalhes do erro",
"@tapToSeeError": { "@tapToSeeError": {
"description": "Tooltip for failed download" "description": "Tooltip for failed download"
}, },
"storeFilterAll": "All", "storeFilterAll": "Todos",
"@storeFilterAll": { "@storeFilterAll": {
"description": "Store filter - all extensions" "description": "Store filter - all extensions"
}, },
"storeFilterMetadata": "Metadata", "storeFilterMetadata": "Metadados",
"@storeFilterMetadata": { "@storeFilterMetadata": {
"description": "Store filter - metadata providers" "description": "Store filter - metadata providers"
}, },
@@ -2101,43 +2101,43 @@
"@storeFilterDownload": { "@storeFilterDownload": {
"description": "Store filter - download providers" "description": "Store filter - download providers"
}, },
"storeFilterUtility": "Utility", "storeFilterUtility": "Utilitário",
"@storeFilterUtility": { "@storeFilterUtility": {
"description": "Store filter - utility extensions" "description": "Store filter - utility extensions"
}, },
"storeFilterLyrics": "Lyrics", "storeFilterLyrics": "Letras",
"@storeFilterLyrics": { "@storeFilterLyrics": {
"description": "Store filter - lyrics providers" "description": "Store filter - lyrics providers"
}, },
"storeFilterIntegration": "Integration", "storeFilterIntegration": "Integração",
"@storeFilterIntegration": { "@storeFilterIntegration": {
"description": "Store filter - integrations" "description": "Store filter - integrations"
}, },
"storeClearFilters": "Clear filters", "storeClearFilters": "Limpar filtros",
"@storeClearFilters": { "@storeClearFilters": {
"description": "Button to clear all filters" "description": "Button to clear all filters"
}, },
"storeNoResults": "No extensions found", "storeNoResults": "Nenhuma extensão encontrada",
"@storeNoResults": { "@storeNoResults": {
"description": "Empty state when no extensions match filters" "description": "Empty state when no extensions match filters"
}, },
"extensionProviderPriority": "Provider Priority", "extensionProviderPriority": "Prioridade de Provedor",
"@extensionProviderPriority": { "@extensionProviderPriority": {
"description": "Extension capability - provider priority" "description": "Extension capability - provider priority"
}, },
"extensionInstallButton": "Install Extension", "extensionInstallButton": "Instalar Extensão",
"@extensionInstallButton": { "@extensionInstallButton": {
"description": "Button to install extension" "description": "Button to install extension"
}, },
"extensionDefaultProvider": "Default (Deezer/Spotify)", "extensionDefaultProvider": "Padrão (Deezer/Spotify)",
"@extensionDefaultProvider": { "@extensionDefaultProvider": {
"description": "Default search provider option" "description": "Default search provider option"
}, },
"extensionDefaultProviderSubtitle": "Use built-in search", "extensionDefaultProviderSubtitle": "Usar pesquisa integrada",
"@extensionDefaultProviderSubtitle": { "@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider" "description": "Subtitle for default provider"
}, },
"extensionAuthor": "Author", "extensionAuthor": "Autor",
"@extensionAuthor": { "@extensionAuthor": {
"description": "Extension detail - author" "description": "Extension detail - author"
}, },
@@ -2145,43 +2145,43 @@
"@extensionId": { "@extensionId": {
"description": "Extension detail - unique ID" "description": "Extension detail - unique ID"
}, },
"extensionError": "Error", "extensionError": "Erro",
"@extensionError": { "@extensionError": {
"description": "Extension detail - error message" "description": "Extension detail - error message"
}, },
"extensionCapabilities": "Capabilities", "extensionCapabilities": "Capacidades",
"@extensionCapabilities": { "@extensionCapabilities": {
"description": "Section header - extension features" "description": "Section header - extension features"
}, },
"extensionMetadataProvider": "Metadata Provider", "extensionMetadataProvider": "Provedor de Metadados",
"@extensionMetadataProvider": { "@extensionMetadataProvider": {
"description": "Capability - provides metadata" "description": "Capability - provides metadata"
}, },
"extensionDownloadProvider": "Download Provider", "extensionDownloadProvider": "Provedor de Download",
"@extensionDownloadProvider": { "@extensionDownloadProvider": {
"description": "Capability - provides downloads" "description": "Capability - provides downloads"
}, },
"extensionLyricsProvider": "Lyrics Provider", "extensionLyricsProvider": "Provedor de Letras",
"@extensionLyricsProvider": { "@extensionLyricsProvider": {
"description": "Capability - provides lyrics" "description": "Capability - provides lyrics"
}, },
"extensionUrlHandler": "URL Handler", "extensionUrlHandler": "Manipulador de URL",
"@extensionUrlHandler": { "@extensionUrlHandler": {
"description": "Capability - handles URLs" "description": "Capability - handles URLs"
}, },
"extensionQualityOptions": "Quality Options", "extensionQualityOptions": "Opções de Qualidade",
"@extensionQualityOptions": { "@extensionQualityOptions": {
"description": "Capability - quality selection" "description": "Capability - quality selection"
}, },
"extensionPostProcessingHooks": "Post-Processing Hooks", "extensionPostProcessingHooks": "Ganchos de Pós-Processamento",
"@extensionPostProcessingHooks": { "@extensionPostProcessingHooks": {
"description": "Capability - post-processing" "description": "Capability - post-processing"
}, },
"extensionPermissions": "Permissions", "extensionPermissions": "Permissões",
"@extensionPermissions": { "@extensionPermissions": {
"description": "Section header - required permissions" "description": "Section header - required permissions"
}, },
"extensionSettings": "Settings", "extensionSettings": "Configurações",
"@extensionSettings": { "@extensionSettings": {
"description": "Section header - extension settings" "description": "Section header - extension settings"
}, },
@@ -2376,31 +2376,31 @@
"@folderNone": { "@folderNone": {
"description": "Folder option - no organization" "description": "Folder option - no organization"
}, },
"folderNoneSubtitle": "Save all files directly to download folder", "folderNoneSubtitle": "Salvar todos os arquivos diretamente na pasta de download",
"@folderNoneSubtitle": { "@folderNoneSubtitle": {
"description": "Subtitle for no folder organization" "description": "Subtitle for no folder organization"
}, },
"folderArtist": "Artist", "folderArtist": "Artista",
"@folderArtist": { "@folderArtist": {
"description": "Folder option - by artist" "description": "Folder option - by artist"
}, },
"folderArtistSubtitle": "Artist Name/filename", "folderArtistSubtitle": "Nome do Artista/nome do arquivo",
"@folderArtistSubtitle": { "@folderArtistSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"folderAlbum": "Album", "folderAlbum": "Álbum",
"@folderAlbum": { "@folderAlbum": {
"description": "Folder option - by album" "description": "Folder option - by album"
}, },
"folderAlbumSubtitle": "Album Name/filename", "folderAlbumSubtitle": "Nome do Álbum/nome do arquivo",
"@folderAlbumSubtitle": { "@folderAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"folderArtistAlbum": "Artist/Album", "folderArtistAlbum": "Artista/Álbum",
"@folderArtistAlbum": { "@folderArtistAlbum": {
"description": "Folder option - nested" "description": "Folder option - nested"
}, },
"folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", "folderArtistAlbumSubtitle": "Nome do Artista/Nome do Álbum/nome do arquivo",
"@folderArtistAlbumSubtitle": { "@folderArtistAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
@@ -2424,103 +2424,103 @@
"@serviceSpotify": { "@serviceSpotify": {
"description": "Service name - DO NOT TRANSLATE" "description": "Service name - DO NOT TRANSLATE"
}, },
"appearanceAmoledDark": "AMOLED Dark", "appearanceAmoledDark": "AMOLED Escuro",
"@appearanceAmoledDark": { "@appearanceAmoledDark": {
"description": "Theme option - pure black" "description": "Theme option - pure black"
}, },
"appearanceAmoledDarkSubtitle": "Pure black background", "appearanceAmoledDarkSubtitle": "Fundo preto puro",
"@appearanceAmoledDarkSubtitle": { "@appearanceAmoledDarkSubtitle": {
"description": "Subtitle for AMOLED dark" "description": "Subtitle for AMOLED dark"
}, },
"appearanceChooseAccentColor": "Choose Accent Color", "appearanceChooseAccentColor": "Escolher Cor de Destaque",
"@appearanceChooseAccentColor": { "@appearanceChooseAccentColor": {
"description": "Color picker dialog title" "description": "Color picker dialog title"
}, },
"appearanceChooseTheme": "Theme Mode", "appearanceChooseTheme": "Modo de Tema",
"@appearanceChooseTheme": { "@appearanceChooseTheme": {
"description": "Theme picker dialog title" "description": "Theme picker dialog title"
}, },
"queueTitle": "Download Queue", "queueTitle": "Fila de Download",
"@queueTitle": { "@queueTitle": {
"description": "Queue screen title" "description": "Queue screen title"
}, },
"queueClearAll": "Clear All", "queueClearAll": "Limpar Tudo",
"@queueClearAll": { "@queueClearAll": {
"description": "Button - clear all queue items" "description": "Button - clear all queue items"
}, },
"queueClearAllMessage": "Are you sure you want to clear all downloads?", "queueClearAllMessage": "Tem certeza de que deseja limpar todos os downloads?",
"@queueClearAllMessage": { "@queueClearAllMessage": {
"description": "Clear queue confirmation" "description": "Clear queue confirmation"
}, },
"queueEmpty": "No downloads in queue", "queueEmpty": "Nenhum download na fila",
"@queueEmpty": { "@queueEmpty": {
"description": "Empty queue state title" "description": "Empty queue state title"
}, },
"queueEmptySubtitle": "Add tracks from the home screen", "queueEmptySubtitle": "Adicione faixas a partir da tela inicial",
"@queueEmptySubtitle": { "@queueEmptySubtitle": {
"description": "Empty queue state subtitle" "description": "Empty queue state subtitle"
}, },
"queueClearCompleted": "Clear completed", "queueClearCompleted": "Limpar concluídos",
"@queueClearCompleted": { "@queueClearCompleted": {
"description": "Button - clear finished downloads" "description": "Button - clear finished downloads"
}, },
"queueDownloadFailed": "Download Failed", "queueDownloadFailed": "Download Falhou",
"@queueDownloadFailed": { "@queueDownloadFailed": {
"description": "Error dialog title" "description": "Error dialog title"
}, },
"queueTrackLabel": "Track:", "queueTrackLabel": "Faixa:",
"@queueTrackLabel": { "@queueTrackLabel": {
"description": "Label in error dialog" "description": "Label in error dialog"
}, },
"queueArtistLabel": "Artist:", "queueArtistLabel": "Artista:",
"@queueArtistLabel": { "@queueArtistLabel": {
"description": "Label in error dialog" "description": "Label in error dialog"
}, },
"queueErrorLabel": "Error:", "queueErrorLabel": "Erro:",
"@queueErrorLabel": { "@queueErrorLabel": {
"description": "Label in error dialog" "description": "Label in error dialog"
}, },
"queueUnknownError": "Unknown error", "queueUnknownError": "Erro desconhecido",
"@queueUnknownError": { "@queueUnknownError": {
"description": "Fallback error message" "description": "Fallback error message"
}, },
"albumFolderArtistAlbum": "Artist / Album", "albumFolderArtistAlbum": "Artista / Álbum",
"@albumFolderArtistAlbum": { "@albumFolderArtistAlbum": {
"description": "Album folder option" "description": "Album folder option"
}, },
"albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/", "albumFolderArtistAlbumSubtitle": "Álbuns/Nome do Artista/Nome do Álbum/",
"@albumFolderArtistAlbumSubtitle": { "@albumFolderArtistAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistYearAlbum": "Artist / [Year] Album", "albumFolderArtistYearAlbum": "Artista / [Ano] Álbum",
"@albumFolderArtistYearAlbum": { "@albumFolderArtistYearAlbum": {
"description": "Album folder option with year" "description": "Album folder option with year"
}, },
"albumFolderArtistYearAlbumSubtitle": "Albums/Artist Name/[2005] Album Name/", "albumFolderArtistYearAlbumSubtitle": "Álbuns/Nome do Artista/[2005] Nome do Álbum/",
"@albumFolderArtistYearAlbumSubtitle": { "@albumFolderArtistYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderAlbumOnly": "Album Only", "albumFolderAlbumOnly": "Apenas Álbum",
"@albumFolderAlbumOnly": { "@albumFolderAlbumOnly": {
"description": "Album folder option" "description": "Album folder option"
}, },
"albumFolderAlbumOnlySubtitle": "Albums/Album Name/", "albumFolderAlbumOnlySubtitle": "Álbuns/Nome do Álbum/",
"@albumFolderAlbumOnlySubtitle": { "@albumFolderAlbumOnlySubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderYearAlbum": "[Year] Album", "albumFolderYearAlbum": "[Ano] Álbum",
"@albumFolderYearAlbum": { "@albumFolderYearAlbum": {
"description": "Album folder option with year" "description": "Album folder option with year"
}, },
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", "albumFolderYearAlbumSubtitle": "Álbuns/[2005] Nome do Álbum/",
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Apagar Selecionados",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
}, },
"downloadedAlbumDeleteMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.", "downloadedAlbumDeleteMessage": "Apagar {count} {count, plural, =1{faixa} other{faixas}} deste álbum?\n\nIsso também apagará os arquivos do armazenamento.",
"@downloadedAlbumDeleteMessage": { "@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count", "description": "Delete confirmation with count",
"placeholders": { "placeholders": {
@@ -2529,11 +2529,11 @@
} }
} }
}, },
"downloadedAlbumTracksHeader": "Tracks", "downloadedAlbumTracksHeader": "Faixas",
"@downloadedAlbumTracksHeader": { "@downloadedAlbumTracksHeader": {
"description": "Section header for tracks" "description": "Section header for tracks"
}, },
"downloadedAlbumDownloadedCount": "{count} downloaded", "downloadedAlbumDownloadedCount": "{count} baixadas",
"@downloadedAlbumDownloadedCount": { "@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge", "description": "Downloaded tracks count badge",
"placeholders": { "placeholders": {
@@ -2542,7 +2542,7 @@
} }
} }
}, },
"downloadedAlbumSelectedCount": "{count} selected", "downloadedAlbumSelectedCount": "{count} selecionadas",
"@downloadedAlbumSelectedCount": { "@downloadedAlbumSelectedCount": {
"description": "Selection count indicator", "description": "Selection count indicator",
"placeholders": { "placeholders": {
@@ -2551,15 +2551,15 @@
} }
} }
}, },
"downloadedAlbumAllSelected": "All tracks selected", "downloadedAlbumAllSelected": "Todas as faixas selecionadas",
"@downloadedAlbumAllSelected": { "@downloadedAlbumAllSelected": {
"description": "Status - all items selected" "description": "Status - all items selected"
}, },
"downloadedAlbumTapToSelect": "Tap tracks to select", "downloadedAlbumTapToSelect": "Toque nas faixas para selecionar",
"@downloadedAlbumTapToSelect": { "@downloadedAlbumTapToSelect": {
"description": "Selection hint" "description": "Selection hint"
}, },
"downloadedAlbumDeleteCount": "Delete {count} {count, plural, =1{track} other{tracks}}", "downloadedAlbumDeleteCount": "Apagar {count} {count, plural, =1{faixa} other{faixas}}",
"@downloadedAlbumDeleteCount": { "@downloadedAlbumDeleteCount": {
"description": "Delete button text with count", "description": "Delete button text with count",
"placeholders": { "placeholders": {
@@ -2568,23 +2568,23 @@
} }
} }
}, },
"downloadedAlbumSelectToDelete": "Select tracks to delete", "downloadedAlbumSelectToDelete": "Selecione faixas para apagar",
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"utilityFunctions": "Utility Functions", "utilityFunctions": "Funções Utilitárias",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
}, },
"recentTypeArtist": "Artist", "recentTypeArtist": "Artista",
"@recentTypeArtist": { "@recentTypeArtist": {
"description": "Recent access item type - artist" "description": "Recent access item type - artist"
}, },
"recentTypeAlbum": "Album", "recentTypeAlbum": "Álbum",
"@recentTypeAlbum": { "@recentTypeAlbum": {
"description": "Recent access item type - album" "description": "Recent access item type - album"
}, },
"recentTypeSong": "Song", "recentTypeSong": "Música",
"@recentTypeSong": { "@recentTypeSong": {
"description": "Recent access item type - song/track" "description": "Recent access item type - song/track"
}, },
@@ -2602,7 +2602,7 @@
} }
} }
}, },
"errorGeneric": "Error: {message}", "errorGeneric": "Erro: {message}",
"@errorGeneric": { "@errorGeneric": {
"description": "Generic error message format", "description": "Generic error message format",
"placeholders": { "placeholders": {
+263 -10
View File
@@ -85,7 +85,7 @@
"@historyFilterSingles": { "@historyFilterSingles": {
"description": "Filter chip - show singles only" "description": "Filter chip - show singles only"
}, },
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", "historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
"@historyTracksCount": { "@historyTracksCount": {
"description": "Track count with plural form", "description": "Track count with plural form",
"placeholders": { "placeholders": {
@@ -94,7 +94,7 @@
} }
} }
}, },
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} other {{count} альбомов}}", "historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} =1 {1 альбом} other {{count} альбомов}}",
"@historyAlbumsCount": { "@historyAlbumsCount": {
"description": "Album count with plural form", "description": "Album count with plural form",
"placeholders": { "placeholders": {
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Поиск в истории...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Настройки", "settingsTitle": "Настройки",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Переводчики",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Особая благодарность", "aboutSpecialThanks": "Особая благодарность",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram канал",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Объявления и обновления",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Сообщество в Telegram",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Чат с другими пользователями",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Соцсети",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Поддержка", "aboutSupport": "Поддержка",
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
@@ -596,7 +624,7 @@
"@albumTitle": { "@albumTitle": {
"description": "Album screen title" "description": "Album screen title"
}, },
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", "albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
"@albumTracks": { "@albumTracks": {
"description": "Album track count", "description": "Album track count",
"placeholders": { "placeholders": {
@@ -633,7 +661,7 @@
"@artistCompilations": { "@artistCompilations": {
"description": "Section header for compilations" "description": "Section header for compilations"
}, },
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} other {{count} релизов}}", "artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} =1 {1 релиз} other {{count} релизов}}",
"@artistReleases": { "@artistReleases": {
"description": "Artist release count", "description": "Artist release count",
"placeholders": { "placeholders": {
@@ -1108,7 +1136,7 @@
"@dialogDeleteSelectedTitle": { "@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items" "description": "Dialog title - delete selected items"
}, },
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.", "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"@dialogDeleteSelectedMessage": { "@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks", "description": "Dialog message - delete selected tracks",
"placeholders": { "placeholders": {
@@ -1122,6 +1150,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?", "dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?",
"csvImportTracks": "{count} треков из CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1169,7 +1206,7 @@
"@snackbarCredentialsCleared": { "@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed" "description": "Snackbar - Spotify credentials removed"
}, },
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"@snackbarDeletedTracks": { "@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted", "description": "Snackbar - tracks deleted",
"placeholders": { "placeholders": {
@@ -1376,7 +1413,7 @@
"@selectionTapToSelect": { "@selectionTapToSelect": {
"description": "Hint - how to select items" "description": "Hint - how to select items"
}, },
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"@selectionDeleteTracks": { "@selectionDeleteTracks": {
"description": "Delete button with count", "description": "Delete button with count",
"placeholders": { "placeholders": {
@@ -1851,6 +1888,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Тексты песен",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Режим текстов песен",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Выберите как сохранить тексты песен при скачивании",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Вставить в файл",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Встроить текст в метаданные FLAC",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "Внешний файл .lrc",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Отдельный файл .lrc для плееров, таких, как Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Оба варианта",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Вставить и сохранить файл .lrc",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Цвет", "sectionColor": "Цвет",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1916,7 +1989,7 @@
} }
} }
}, },
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", "tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
"@tracksCount": { "@tracksCount": {
"description": "Track count display", "description": "Track count display",
"placeholders": { "placeholders": {
@@ -1997,6 +2070,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Жанр",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Заголовок",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Авторские права",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Скачано", "trackDownloaded": "Скачано",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2102,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Вставить текст песни",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Текст успешно добавлен",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Инструментальный трек",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Скопировано в буфер обмена", "trackCopiedToClipboard": "Скопировано в буфер обмена",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320 кбит/с (Конвертировано из FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Скачивние в MP3",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 качество доступно",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Скачивать FLAC и конвертировать в MP3 320 кбит/с",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Фактическое качество зависит от доступности треков в сервисе", "qualityNote": "Фактическое качество зависит от доступности треков в сервисе",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,11 +2633,19 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Исполнитель / Альбом + Синглы",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Исполнитель/Альбом и Исполнитель/Сингл/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Удалить выбранные", "downloadedAlbumDeleteSelected": "Удалить выбранные",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
}, },
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.", "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"@downloadedAlbumDeleteMessage": { "@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count", "description": "Delete confirmation with count",
"placeholders": { "placeholders": {
@@ -2559,7 +2684,7 @@
"@downloadedAlbumTapToSelect": { "@downloadedAlbumTapToSelect": {
"description": "Selection hint" "description": "Selection hint"
}, },
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"@downloadedAlbumDeleteCount": { "@downloadedAlbumDeleteCount": {
"description": "Delete button text with count", "description": "Delete button text with count",
"placeholders": { "placeholders": {
@@ -2572,6 +2697,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Диск {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Функции утилиты", "utilityFunctions": "Функции утилиты",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2746,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Скачать дискографию",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Скачать всё",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} треков из {albumCount} релизов",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Только альбомы",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} треков из {albumCount} альбомов",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Только синглы и EP",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} треков из {albumCount} синглов",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Выбрать альбомы...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Выберите конкретные альбомы или синглы",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Получение треков...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Получение {current} из {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} выбрано",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Скачать выбранное",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Добавлено {count} треков в очередь",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} добавлено, {skipped} уже скачано",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "Нет доступных альбомов",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Не удалось получить некоторые альбомы",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+2865 -4
View File
File diff suppressed because it is too large Load Diff
+253
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
@@ -1122,6 +1150,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1888,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2070,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2102,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2633,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2697,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2746,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+253
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": { "@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter" "description": "Empty state subtitle for singles filter"
}, },
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings", "settingsTitle": "Settings",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": { "@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request" "description": "Subtitle for feature request"
}, },
"aboutTelegramChannel": "Telegram Channel",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"@aboutSocial": {
"description": "Section for social links"
},
"aboutSupport": "Support", "aboutSupport": "Support",
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
@@ -1122,6 +1150,15 @@
"description": "Dialog title - import CSV playlist" "description": "Dialog title - import CSV playlist"
}, },
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": { "@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation", "description": "Dialog message - import playlist confirmation",
"placeholders": { "placeholders": {
@@ -1851,6 +1888,42 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
"sectionColor": "Color", "sectionColor": "Color",
"@sectionColor": { "@sectionColor": {
"description": "Settings section header" "description": "Settings section header"
@@ -1997,6 +2070,18 @@
"@trackReleaseDate": { "@trackReleaseDate": {
"description": "Metadata label - release date" "description": "Metadata label - release date"
}, },
"trackGenre": "Genre",
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
"trackDownloaded": "Downloaded", "trackDownloaded": "Downloaded",
"@trackDownloaded": { "@trackDownloaded": {
"description": "Metadata label - download date" "description": "Metadata label - download date"
@@ -2017,6 +2102,18 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
"trackCopiedToClipboard": "Copied to clipboard", "trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": { "@trackCopiedToClipboard": {
"description": "Snackbar - content copied" "description": "Snackbar - content copied"
@@ -2328,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"qualityMp3": "MP3",
"@qualityMp3": {
"description": "Quality option - MP3 lossy format"
},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {
"description": "Technical spec for MP3"
},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {
"description": "Setting - enable MP3 quality option"
},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {
"description": "Subtitle when MP3 is enabled"
},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {
"description": "Subtitle when MP3 is disabled"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -2516,6 +2633,14 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -2572,6 +2697,16 @@
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {
"type": "int",
"example": "1"
}
}
},
"utilityFunctions": "Utility Functions", "utilityFunctions": "Utility Functions",
"@utilityFunctions": { "@utilityFunctions": {
"description": "Extension capability - utility functions" "description": "Extension capability - utility functions"
@@ -2611,5 +2746,123 @@
"description": "Error message" "description": "Error message"
} }
} }
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographyAlbumsOnly": "Albums Only",
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySinglesOnly": "Singles & EPs Only",
"@discographySinglesOnly": {
"description": "Option - download only singles"
},
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
"@discographySinglesOnlySubtitle": {
"description": "Subtitle showing singles tracks count",
"placeholders": {
"count": {
"type": "int"
},
"albumCount": {
"type": "int"
}
}
},
"discographySelectAlbums": "Select Albums...",
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
"discographyFetchingTracks": "Fetching tracks...",
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"discographySelectedCount": "{count} selected",
"@discographySelectedCount": {
"description": "Selection count badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
"count": {
"type": "int"
}
}
},
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
"@discographySkippedDownloaded": {
"description": "Snackbar - with skipped tracks count",
"placeholders": {
"added": {
"type": "int"
},
"skipped": {
"type": "int"
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
} }
} }
+4
View File
@@ -18,6 +18,8 @@ const List<Locale> filteredSupportedLocales = <Locale>[
Locale('es', 'ES'), Locale('es', 'ES'),
Locale('id'), Locale('id'),
Locale('pt', 'PT'), Locale('pt', 'PT'),
Locale('ja'),
Locale('tr'),
]; ];
/// Set of locale codes for quick lookup. /// Set of locale codes for quick lookup.
@@ -27,4 +29,6 @@ const Set<String> filteredLocaleCodes = <String>{
'es_ES', 'es_ES',
'id', 'id',
'pt_PT', 'pt_PT',
'ja',
'tr',
}; };
+1 -1
View File
@@ -43,6 +43,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
void initState() { void initState() {
super.initState(); super.initState();
_initializeExtensions(); _initializeExtensions();
ref.read(downloadHistoryProvider);
} }
Future<void> _initializeExtensions() async { Future<void> _initializeExtensions() async {
@@ -62,7 +63,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
ref.watch(downloadHistoryProvider);
return widget.child; return widget.child;
} }
} }
+12 -4
View File
@@ -31,8 +31,10 @@ class AppSettings {
final String albumFolderStructure; final String albumFolderStructure;
final bool showExtensionStore; final bool showExtensionStore;
final String locale; final String locale;
final bool enableMp3Option;
final String lyricsMode; final String lyricsMode;
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128'
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
const AppSettings({ const AppSettings({
this.defaultService = 'tidal', this.defaultService = 'tidal',
@@ -62,8 +64,10 @@ class AppSettings {
this.albumFolderStructure = 'artist_album', this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true, this.showExtensionStore = true,
this.locale = 'system', this.locale = 'system',
this.enableMp3Option = false,
this.lyricsMode = 'embed', this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.useAllFilesAccess = false,
this.autoExportFailedDownloads = false,
}); });
AppSettings copyWith({ AppSettings copyWith({
@@ -95,8 +99,10 @@ class AppSettings {
String? albumFolderStructure, String? albumFolderStructure,
bool? showExtensionStore, bool? showExtensionStore,
String? locale, String? locale,
bool? enableMp3Option,
String? lyricsMode, String? lyricsMode,
String? tidalHighFormat,
bool? useAllFilesAccess,
bool? autoExportFailedDownloads,
}) { }) {
return AppSettings( return AppSettings(
defaultService: defaultService ?? this.defaultService, defaultService: defaultService ?? this.defaultService,
@@ -126,8 +132,10 @@ class AppSettings {
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore, showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale, locale: locale ?? this.locale,
enableMp3Option: enableMp3Option ?? this.enableMp3Option,
lyricsMode: lyricsMode ?? this.lyricsMode, lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
); );
} }
+7 -2
View File
@@ -36,8 +36,11 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['albumFolderStructure'] as String? ?? 'artist_album', json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true, showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system', locale: json['locale'] as String? ?? 'system',
enableMp3Option: json['enableMp3Option'] as bool? ?? false,
lyricsMode: json['lyricsMode'] as String? ?? 'embed', lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
autoExportFailedDownloads:
json['autoExportFailedDownloads'] as bool? ?? false,
); );
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) => Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -69,6 +72,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'albumFolderStructure': instance.albumFolderStructure, 'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore, 'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale, 'locale': instance.locale,
'enableMp3Option': instance.enableMp3Option,
'lyricsMode': instance.lyricsMode, 'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'useAllFilesAccess': instance.useAllFilesAccess,
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
}; };
+389 -89
View File
@@ -150,15 +150,12 @@ class DownloadHistoryState {
.map((item) => MapEntry(item.isrc!, item)), .map((item) => MapEntry(item.isrc!, item)),
); );
/// O(1) check if spotify_id exists
bool isDownloaded(String spotifyId) => bool isDownloaded(String spotifyId) =>
_downloadedSpotifyIds.contains(spotifyId); _downloadedSpotifyIds.contains(spotifyId);
/// O(1) lookup by spotify_id
DownloadHistoryItem? getBySpotifyId(String spotifyId) => DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
_bySpotifyId[spotifyId]; _bySpotifyId[spotifyId];
/// O(1) lookup by ISRC
DownloadHistoryItem? getByIsrc(String isrc) => DownloadHistoryItem? getByIsrc(String isrc) =>
_byIsrc[isrc]; _byIsrc[isrc];
@@ -177,12 +174,11 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return DownloadHistoryState(); return DownloadHistoryState();
} }
/// Synchronously schedule load - ensures it runs before any UI renders
void _loadFromDatabaseSync() { void _loadFromDatabaseSync() {
if (_isLoaded) return; if (_isLoaded) return;
_isLoaded = true;
Future.microtask(() async { Future.microtask(() async {
await _loadFromDatabase(); await _loadFromDatabase();
_isLoaded = true;
}); });
} }
@@ -193,6 +189,13 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_historyLog.i('Migrated history from SharedPreferences to SQLite'); _historyLog.i('Migrated history from SharedPreferences to SQLite');
} }
if (Platform.isIOS) {
final pathsMigrated = await _db.migrateIosContainerPaths();
if (pathsMigrated) {
_historyLog.i('Migrated iOS container paths after app update');
}
}
final jsonList = await _db.getAll(); final jsonList = await _db.getAll();
final items = jsonList final items = jsonList
.map((e) => DownloadHistoryItem.fromJson(e)) .map((e) => DownloadHistoryItem.fromJson(e))
@@ -256,12 +259,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return state.getBySpotifyId(spotifyId); return state.getBySpotifyId(spotifyId);
} }
/// O(1) lookup by ISRC
DownloadHistoryItem? getByIsrc(String isrc) { DownloadHistoryItem? getByIsrc(String isrc) {
return state.getByIsrc(isrc); return state.getByIsrc(isrc);
} }
/// Async version with database lookup (for cases where in-memory might be stale)
Future<DownloadHistoryItem?> getBySpotifyIdAsync(String spotifyId) async { Future<DownloadHistoryItem?> getBySpotifyIdAsync(String spotifyId) async {
final inMemory = state.getBySpotifyId(spotifyId); final inMemory = state.getBySpotifyId(spotifyId);
if (inMemory != null) return inMemory; if (inMemory != null) return inMemory;
@@ -278,7 +279,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}); });
} }
/// Get database stats for debugging
Future<int> getDatabaseCount() async { Future<int> getDatabaseCount() async {
return await _db.getCount(); return await _db.getCount();
} }
@@ -467,10 +467,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final currentItems = state.items; final currentItems = state.items;
final itemsById = <String, DownloadItem>{}; final itemsById = <String, DownloadItem>{};
final itemIndexById = <String, int>{}; final itemIndexById = <String, int>{};
int queuedCount = 0;
int downloadingCount = 0;
DownloadItem? firstDownloading;
for (int i = 0; i < currentItems.length; i++) { for (int i = 0; i < currentItems.length; i++) {
final item = currentItems[i]; final item = currentItems[i];
itemsById[item.id] = item; itemsById[item.id] = item;
itemIndexById[item.id] = i; itemIndexById[item.id] = i;
if (item.status == DownloadStatus.downloading) {
downloadingCount++;
firstDownloading ??= item;
}
if (item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading) {
queuedCount++;
}
} }
final progressUpdates = <String, _ProgressUpdate>{}; final progressUpdates = <String, _ProgressUpdate>{};
@@ -592,15 +603,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0; final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0; final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
final downloadingItems = state.items if (downloadingCount > 0 && firstDownloading != null) {
.where((i) => i.status == DownloadStatus.downloading) final trackName = downloadingCount == 1
.toList(); ? firstDownloading.track.name
if (downloadingItems.isNotEmpty) { : '$downloadingCount downloads';
final trackName = downloadingItems.length == 1 final artistName = downloadingCount == 1
? downloadingItems.first.track.name ? firstDownloading.track.artistName
: '${downloadingItems.length} downloads';
final artistName = downloadingItems.length == 1
? downloadingItems.first.track.artistName
: 'Downloading...'; : 'Downloading...';
int notifProgress = bytesReceived; int notifProgress = bytesReceived;
@@ -622,11 +630,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (Platform.isAndroid) { if (Platform.isAndroid) {
PlatformBridge.updateDownloadServiceProgress( PlatformBridge.updateDownloadServiceProgress(
trackName: downloadingItems.first.track.name, trackName: firstDownloading.track.name,
artistName: downloadingItems.first.track.artistName, artistName: firstDownloading.track.artistName,
progress: notifProgress, progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1, total: notifTotal > 0 ? notifTotal : 1,
queueCount: state.queuedCount, queueCount: queuedCount,
).catchError((_) {}); ).catchError((_) {});
} }
} }
@@ -704,6 +712,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (separateSingles) { if (separateSingles) {
final isSingle = track.isSingle; final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist);
if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles';
await _ensureDirExists(singlesPath, label: 'Artist Singles folder');
return singlesPath;
} else {
final albumName = _sanitizeFolderName(track.albumName);
final albumPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
await _ensureDirExists(albumPath, label: 'Artist Album folder');
return albumPath;
}
}
if (isSingle) { if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
@@ -711,7 +733,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return singlesPath; return singlesPath;
} else { } else {
final albumName = _sanitizeFolderName(track.albumName); final albumName = _sanitizeFolderName(track.albumName);
final artistName = _sanitizeFolderName(albumArtist);
final year = _extractYear(track.releaseDate); final year = _extractYear(track.releaseDate);
String albumPath; String albumPath;
@@ -773,7 +794,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.trim(); .trim();
} }
/// Extract year from release date (format: "2005-06-13" or "2005")
String? _extractYear(String? releaseDate) { String? _extractYear(String? releaseDate) {
if (releaseDate == null || releaseDate.isEmpty) return null; if (releaseDate == null || releaseDate.isEmpty) return null;
final match = _yearRegex.firstMatch(releaseDate); final match = _yearRegex.firstMatch(releaseDate);
@@ -992,12 +1012,74 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
void removeItem(String id) { void removeItem(String id) {
final items = state.items.where((item) => item.id != id).toList(); final items = state.items.where((item) => item.id != id).toList();
state = state.copyWith(items: items); state = state.copyWith(items: items);
_saveQueueToStorage(); _saveQueueToStorage();
} }
/// Export failed downloads to a TXT file
/// Returns the file path if successful, null otherwise
Future<String?> exportFailedDownloads() async {
final failedItems = state.items
.where((item) => item.status == DownloadStatus.failed)
.toList();
if (failedItems.isEmpty) {
_log.d('No failed downloads to export');
return null;
}
try {
final buffer = StringBuffer();
buffer.writeln('# SpotiFLAC Failed Downloads');
buffer.writeln('# Exported: ${DateTime.now().toIso8601String()}');
buffer.writeln('# Total: ${failedItems.length} tracks');
buffer.writeln('#');
buffer.writeln('# Format: Track - Artist | Spotify URL | Error');
buffer.writeln('');
for (final item in failedItems) {
final track = item.track;
final spotifyUrl = track.id.startsWith('deezer:')
? 'https://www.deezer.com/track/${track.id.substring(7)}'
: 'https://open.spotify.com/track/${track.id}';
final error = item.error ?? 'Unknown error';
buffer.writeln('${track.name} - ${track.artistName} | $spotifyUrl | $error');
}
// Save to download directory
String exportDir = state.outputDir;
if (exportDir.isEmpty) {
final dir = await getApplicationDocumentsDirectory();
exportDir = dir.path;
}
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').split('.').first;
final fileName = 'failed_downloads_$timestamp.txt';
final filePath = '$exportDir/$fileName';
final file = File(filePath);
await file.writeAsString(buffer.toString());
_log.i('Exported ${failedItems.length} failed downloads to: $filePath');
return filePath;
} catch (e) {
_log.e('Failed to export failed downloads: $e');
return null;
}
}
/// Clear all failed downloads from queue
void clearFailedDownloads() {
final items = state.items
.where((item) => item.status != DownloadStatus.failed)
.toList();
state = state.copyWith(items: items);
_saveQueueToStorage();
_log.d('Cleared failed downloads from queue');
}
Future<void> _runPostProcessingHooks(String filePath, Track track) async { Future<void> _runPostProcessingHooks(String filePath, Track track) async {
try { try {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
@@ -1044,7 +1126,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
/// Same logic as Go backend cover.go
String _upgradeToMaxQualityCover(String coverUrl) { String _upgradeToMaxQualityCover(String coverUrl) {
const spotifySize300 = 'ab67616d00001e02'; const spotifySize300 = 'ab67616d00001e02';
const spotifySize640 = 'ab67616d0000b273'; const spotifySize640 = 'ab67616d0000b273';
@@ -1161,10 +1242,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
durationMs: durationMs, durationMs: durationMs,
); );
if (lrcContent.isNotEmpty) { if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
metadata['LYRICS'] = lrcContent; metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent; metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
} else if (lrcContent == '[instrumental:true]') {
_log.d('Track is instrumental, skipping lyrics embedding');
} }
} catch (e) { } catch (e) {
_log.w('Failed to fetch lyrics for embedding: $e'); _log.w('Failed to fetch lyrics for embedding: $e');
@@ -1289,7 +1372,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('MP3 Metadata map content: $metadata'); _log.d('MP3 Metadata map content: $metadata');
if (settings.embedLyrics) { final lyricsMode = settings.lyricsMode;
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) {
try { try {
final durationMs = track.duration * 1000; final durationMs = track.duration * 1000;
@@ -1302,12 +1389,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
if (lrcContent.isNotEmpty) { if (lrcContent.isNotEmpty) {
metadata['LYRICS'] = lrcContent; if (shouldEmbed) {
metadata['UNSYNCEDLYRICS'] = lrcContent; metadata['LYRICS'] = lrcContent;
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)'); metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
}
if (shouldSaveExternal) {
try {
final lrcPath = mp3Path.replaceAll(RegExp(r'\.mp3$', caseSensitive: false), '.lrc');
await File(lrcPath).writeAsString(lrcContent);
_log.d('External LRC file saved: $lrcPath');
} catch (e) {
_log.w('Failed to save external LRC file: $e');
}
}
} }
} catch (e) { } catch (e) {
_log.w('Failed to fetch lyrics for MP3 embedding: $e'); _log.w('Failed to fetch lyrics for MP3: $e');
} }
} }
@@ -1342,6 +1441,162 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
Future<void> _embedMetadataToOpus(
String opusPath,
Track track, {
String? genre,
String? label,
String? copyright,
}) async {
final settings = ref.read(settingsProvider);
String? coverPath;
var coverUrl = track.coverUrl;
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
if (settings.maxQualityCover) {
coverUrl = _upgradeToMaxQualityCover(coverUrl);
_log.d('Cover URL upgraded to max quality for Opus: $coverUrl');
}
final tempDir = await getTemporaryDirectory();
final uniqueId =
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
coverPath = '${tempDir.path}/cover_opus_$uniqueId.jpg';
final httpClient = HttpClient();
final request = await httpClient.getUrl(Uri.parse(coverUrl));
final response = await request.close();
if (response.statusCode == 200) {
final file = File(coverPath);
final sink = file.openWrite();
await response.pipe(sink);
await sink.close();
_log.d('Cover downloaded for Opus: $coverPath');
} else {
_log.w('Failed to download cover for Opus: HTTP ${response.statusCode}');
coverPath = null;
}
httpClient.close();
} catch (e) {
_log.e('Failed to download cover for Opus: $e');
coverPath = null;
}
}
try {
final metadata = <String, String>{
'TITLE': track.name,
'ARTIST': track.artistName,
'ALBUM': track.albumName,
};
final albumArtist = _normalizeOptionalString(track.albumArtist) ??
track.artistName;
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
metadata['DISCNUMBER'] = track.discNumber.toString();
}
if (track.releaseDate != null) {
metadata['DATE'] = track.releaseDate!;
}
if (track.isrc != null) {
metadata['ISRC'] = track.isrc!;
}
if (genre != null && genre.isNotEmpty) {
metadata['GENRE'] = genre;
_log.d('Adding GENRE to Opus: $genre');
}
if (label != null && label.isNotEmpty) {
metadata['ORGANIZATION'] = label;
_log.d('Adding ORGANIZATION (label) to Opus: $label');
}
if (copyright != null && copyright.isNotEmpty) {
metadata['COPYRIGHT'] = copyright;
_log.d('Adding COPYRIGHT to Opus: $copyright');
}
_log.d('Opus Metadata map content: $metadata');
// Handle lyrics based on lyricsMode setting
final lyricsMode = settings.lyricsMode;
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) {
try {
final durationMs = track.duration * 1000;
final lrcContent = await PlatformBridge.getLyricsLRC(
track.id,
track.name,
track.artistName,
filePath: '',
durationMs: durationMs,
);
if (lrcContent.isNotEmpty) {
// Embed lyrics in file metadata if mode is 'embed' or 'both'
if (shouldEmbed) {
metadata['LYRICS'] = lrcContent;
_log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)');
}
// Save external LRC file if mode is 'external' or 'both'
if (shouldSaveExternal) {
try {
final lrcPath = opusPath.replaceAll(RegExp(r'\.opus$', caseSensitive: false), '.lrc');
await File(lrcPath).writeAsString(lrcContent);
_log.d('External LRC file saved: $lrcPath');
} catch (e) {
_log.w('Failed to save external LRC file: $e');
}
}
}
} catch (e) {
_log.w('Failed to fetch lyrics for Opus: $e');
}
}
_log.d('Embedding tags to Opus: $metadata');
final result = await FFmpegService.embedMetadataToOpus(
opusPath: opusPath,
coverPath: coverPath != null && await File(coverPath).exists()
? coverPath
: null,
metadata: metadata,
);
if (result != null) {
_log.d('Metadata, lyrics, and cover embedded to Opus via FFmpeg');
} else {
_log.w('FFmpeg Opus metadata/cover embed failed');
}
if (coverPath != null) {
try {
final coverFile = File(coverPath);
if (await coverFile.exists()) {
await coverFile.delete();
}
} catch (e) {
_log.w('Failed to cleanup Opus cover file: $e');
}
}
} catch (e) {
_log.e('Failed to embed metadata to Opus: $e');
}
}
Future<void> _processQueue() async { Future<void> _processQueue() async {
if (state.isProcessing) return; if (state.isProcessing) return;
@@ -1371,11 +1626,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
if (state.outputDir.isEmpty) { if (state.outputDir.isEmpty) {
_log.d('Output dir empty, initializing...'); _log.d('Output dir empty, initializing...');
await _initOutputDir(); await _initOutputDir();
} }
// iOS: Validate that outputDir is writable (not iCloud Drive which Go can't access)
if (Platform.isIOS && state.outputDir.isNotEmpty) {
final isICloudPath = state.outputDir.contains('Mobile Documents') ||
state.outputDir.contains('CloudDocs') ||
state.outputDir.contains('com~apple~CloudDocs');
if (isICloudPath) {
_log.w('iOS: iCloud Drive path detected, falling back to app Documents folder');
_log.w('Go backend cannot write to iCloud Drive due to iOS sandboxing');
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/SpotiFLAC');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
state = state.copyWith(outputDir: musicDir.path);
}
}
if (state.outputDir.isEmpty) { if (state.outputDir.isEmpty) {
_log.d('Using fallback directory...'); _log.d('Using fallback directory...');
final dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationDocumentsDirectory();
@@ -1416,7 +1688,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_downloadCount = 0; _downloadCount = 0;
} }
_log.i( _log.i(
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart', 'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
); );
if (_totalQueuedAtStart > 0) { if (_totalQueuedAtStart > 0) {
@@ -1424,6 +1696,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
completedCount: _completedInSession, completedCount: _completedInSession,
failedCount: _failedInSession, failedCount: _failedInSession,
); );
// Auto-export failed downloads if enabled
final settings = ref.read(settingsProvider);
if (settings.autoExportFailedDownloads && _failedInSession > 0) {
final exportPath = await exportFailedDownloads();
if (exportPath != null) {
_log.i('Auto-exported failed downloads to: $exportPath');
}
}
} }
_log.i('Queue processing finished'); _log.i('Queue processing finished');
@@ -1634,7 +1915,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final quality = item.qualityOverride ?? state.audioQuality; final quality = item.qualityOverride ?? state.audioQuality;
// Fetch extended metadata (genre, label) from Deezer if available
String? genre; String? genre;
String? label; String? label;
@@ -1686,7 +1966,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
); );
_log.d('Output dir: $outputDir'); _log.d('Output dir: $outputDir');
result = await PlatformBridge.downloadWithExtensions( result = await PlatformBridge.downloadWithExtensions(
isrc: trackToDownload.isrc ?? '', isrc: trackToDownload.isrc ?? '',
spotifyId: trackToDownload.id, spotifyId: trackToDownload.id,
trackName: trackToDownload.name, trackName: trackToDownload.name,
@@ -1706,6 +1986,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
genre: genre, genre: genre,
label: label, label: label,
lyricsMode: settings.lyricsMode, lyricsMode: settings.lyricsMode,
preferredService: item.service,
); );
} else if (state.autoFallback) { } else if (state.autoFallback) {
_log.d('Using auto-fallback mode'); _log.d('Using auto-fallback mode');
@@ -1805,9 +2086,77 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
if (filePath != null && filePath.endsWith('.m4a')) { if (filePath != null && filePath.endsWith('.m4a')) {
_log.d( // For HIGH quality (Tidal AAC 320kbps), convert to MP3 or Opus
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...', if (quality == 'HIGH') {
); final tidalHighFormat = settings.tidalHighFormat;
_log.i('Tidal HIGH quality download, converting M4A to $tidalHighFormat...');
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
// Convert M4A to the selected format
final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3';
final convertedPath = await FFmpegService.convertM4aToLossy(
filePath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: true,
);
if (convertedPath != null) {
filePath = convertedPath;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
_log.i('Successfully converted M4A to $format: $convertedPath');
// Embed metadata
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (format == 'mp3') {
await _embedMetadataToMp3(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
_log.d('Metadata embedded successfully');
} else {
_log.w('M4A to $format conversion failed, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('M4A conversion process failed: $e, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} else {
_log.d(
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
);
try { try {
final file = File(filePath); final file = File(filePath);
@@ -1909,6 +2258,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} catch (e) { } catch (e) {
_log.w('FFmpeg conversion process failed: $e, keeping M4A file'); _log.w('FFmpeg conversion process failed: $e, keeping M4A file');
} }
}
} }
final itemAfterDownload = state.items.firstWhere( final itemAfterDownload = state.items.firstWhere(
@@ -1931,56 +2281,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return; return;
} }
if (quality == 'MP3' && filePath != null && filePath.endsWith('.flac')) {
if (wasExisting) {
_log.i('MP3 requested but existing FLAC found - skipping conversion to preserve original file');
} else {
_log.i('MP3 quality selected, converting FLAC to MP3...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.97,
);
try {
final mp3Path = await FFmpegService.convertFlacToMp3(
filePath,
bitrate: '320k',
deleteOriginal: true,
);
if (mp3Path != null) {
filePath = mp3Path;
actualQuality = 'MP3 320kbps';
_log.i('Successfully converted to MP3: $mp3Path');
_log.i('Embedding metadata to MP3...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
final mp3BackendGenre = result['genre'] as String?;
final mp3BackendLabel = result['label'] as String?;
final mp3BackendCopyright = result['copyright'] as String?;
await _embedMetadataToMp3(
mp3Path,
trackToDownload,
genre: mp3BackendGenre ?? genre,
label: mp3BackendLabel ?? label,
copyright: mp3BackendCopyright,
);
} else {
_log.w('MP3 conversion failed, keeping FLAC file');
}
} catch (e) {
_log.e('MP3 conversion error: $e, keeping FLAC file');
}
}
}
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.completed, DownloadStatus.completed,
+39 -4
View File
@@ -55,21 +55,26 @@ class ExploreSection {
final String uri; final String uri;
final String title; final String title;
final List<ExploreItem> items; final List<ExploreItem> items;
final bool isYTMusicQuickPicks;
const ExploreSection({ const ExploreSection({
required this.uri, required this.uri,
required this.title, required this.title,
required this.items, required this.items,
this.isYTMusicQuickPicks = false,
}); });
factory ExploreSection.fromJson(Map<String, dynamic> json) { factory ExploreSection.fromJson(Map<String, dynamic> json) {
final itemsList = json['items'] as List<dynamic>? ?? []; final itemsList = json['items'] as List<dynamic>? ?? [];
final items = itemsList
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>))
.toList();
final isQuickPicks = _isYTMusicQuickPicksItems(items);
return ExploreSection( return ExploreSection(
uri: json['uri'] as String? ?? '', uri: json['uri'] as String? ?? '',
title: json['title'] as String? ?? '', title: json['title'] as String? ?? '',
items: itemsList items: items,
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>)) isYTMusicQuickPicks: isQuickPicks,
.toList(),
); );
} }
} }
@@ -109,6 +114,31 @@ class ExploreState {
} }
} }
/// Calculate greeting based on local device time
String _getLocalGreeting() {
final hour = DateTime.now().hour;
if (hour >= 5 && hour < 12) {
return 'Good morning';
} else if (hour >= 12 && hour < 17) {
return 'Good afternoon';
} else if (hour >= 17 && hour < 21) {
return 'Good evening';
} else {
return 'Good night';
}
}
bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
if (items.isEmpty) return false;
if (items.first.providerId != 'ytmusic-spotiflac') return false;
for (final item in items) {
if (item.type != 'track') {
return false;
}
}
return true;
}
/// Provider for explore/home feed state /// Provider for explore/home feed state
class ExploreNotifier extends Notifier<ExploreState> { class ExploreNotifier extends Notifier<ExploreState> {
@override @override
@@ -201,9 +231,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}'); _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');
state = ExploreState( state = ExploreState(
isLoading: false, isLoading: false,
greeting: greeting, greeting: localGreeting,
sections: sections, sections: sections,
lastFetched: DateTime.now(), lastFetched: DateTime.now(),
); );
+25
View File
@@ -146,6 +146,26 @@ class Extension {
bool get hasBrowseCategories => capabilities['browseCategories'] == true; bool get hasBrowseCategories => capabilities['browseCategories'] == true;
} }
class SearchFilter {
final String id;
final String? label;
final String? icon;
const SearchFilter({
required this.id,
this.label,
this.icon,
});
factory SearchFilter.fromJson(Map<String, dynamic> json) {
return SearchFilter(
id: json['id'] as String? ?? '',
label: json['label'] as String?,
icon: json['icon'] as String?,
);
}
}
class SearchBehavior { class SearchBehavior {
final bool enabled; final bool enabled;
final String? placeholder; final String? placeholder;
@@ -154,6 +174,7 @@ class SearchBehavior {
final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3) final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
final int? thumbnailWidth; final int? thumbnailWidth;
final int? thumbnailHeight; final int? thumbnailHeight;
final List<SearchFilter> filters; // Available search filters (e.g., track, album, artist, playlist)
const SearchBehavior({ const SearchBehavior({
required this.enabled, required this.enabled,
@@ -163,6 +184,7 @@ class SearchBehavior {
this.thumbnailRatio, this.thumbnailRatio,
this.thumbnailWidth, this.thumbnailWidth,
this.thumbnailHeight, this.thumbnailHeight,
this.filters = const [],
}); });
factory SearchBehavior.fromJson(Map<String, dynamic> json) { factory SearchBehavior.fromJson(Map<String, dynamic> json) {
@@ -174,6 +196,9 @@ class SearchBehavior {
thumbnailRatio: json['thumbnailRatio'] as String?, thumbnailRatio: json['thumbnailRatio'] as String?,
thumbnailWidth: json['thumbnailWidth'] as int?, thumbnailWidth: json['thumbnailWidth'] as int?,
thumbnailHeight: json['thumbnailHeight'] as int?, thumbnailHeight: json['thumbnailHeight'] as int?,
filters: (json['filters'] as List<dynamic>?)
?.map((f) => SearchFilter.fromJson(f as Map<String, dynamic>))
.toList() ?? [],
); );
} }
+7 -4
View File
@@ -100,6 +100,8 @@ class RecentAccessState {
/// Provider for managing recent access history /// Provider for managing recent access history
class RecentAccessNotifier extends Notifier<RecentAccessState> { class RecentAccessNotifier extends Notifier<RecentAccessState> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override @override
RecentAccessState build() { RecentAccessState build() {
_loadHistory(); _loadHistory();
@@ -107,7 +109,7 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
} }
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
final json = prefs.getString(_recentAccessKey); final json = prefs.getString(_recentAccessKey);
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey); final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
@@ -120,7 +122,8 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
items = decoded items = decoded
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>)) .map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
} catch (e) { } catch (_) {
// Ignore JSON parse errors, use empty list
} }
} }
@@ -132,13 +135,13 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
} }
Future<void> _saveHistory() async { Future<void> _saveHistory() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
final json = jsonEncode(state.items.map((e) => e.toJson()).toList()); final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
await prefs.setString(_recentAccessKey, json); await prefs.setString(_recentAccessKey, json);
} }
Future<void> _saveHiddenDownloads() async { Future<void> _saveHiddenDownloads() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList()); await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
} }
+16 -8
View File
@@ -10,6 +10,8 @@ const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 1; const _currentMigrationVersion = 1;
class SettingsNotifier extends Notifier<AppSettings> { class SettingsNotifier extends Notifier<AppSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override @override
AppSettings build() { AppSettings build() {
_loadSettings(); _loadSettings();
@@ -17,7 +19,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
final json = prefs.getString(_settingsKey); final json = prefs.getString(_settingsKey);
if (json != null) { if (json != null) {
state = AppSettings.fromJson(jsonDecode(json)); state = AppSettings.fromJson(jsonDecode(json));
@@ -46,7 +48,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> _saveSettings() async { Future<void> _saveSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
await prefs.setString(_settingsKey, jsonEncode(state.toJson())); await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
} }
@@ -229,12 +231,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setEnableMp3Option(bool enabled) { void setTidalHighFormat(String format) {
state = state.copyWith(enableMp3Option: enabled); state = state.copyWith(tidalHighFormat: format);
// If MP3 is disabled and current quality is MP3, reset to LOSSLESS _saveSettings();
if (!enabled && state.audioQuality == 'MP3') { }
state = state.copyWith(audioQuality: 'LOSSLESS');
} void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings();
}
void setAutoExportFailedDownloads(bool enabled) {
state = state.copyWith(autoExportFailedDownloads: enabled);
_saveSettings(); _saveSettings();
} }
} }
+3 -2
View File
@@ -5,12 +5,13 @@ import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('StoreProvider'); final _log = AppLogger('StoreProvider');
final RegExp _leadingVersionPrefix = RegExp(r'^v');
/// Compare two semantic version strings /// Compare two semantic version strings
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2 /// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
int compareVersions(String v1, String v2) { int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.'); final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.'); final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length; final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
+4 -2
View File
@@ -10,6 +10,8 @@ final themeProvider = NotifierProvider<ThemeNotifier, ThemeSettings>(() {
/// Notifier for managing theme settings with persistence /// Notifier for managing theme settings with persistence
class ThemeNotifier extends Notifier<ThemeSettings> { class ThemeNotifier extends Notifier<ThemeSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override @override
ThemeSettings build() { ThemeSettings build() {
// Load settings asynchronously on first access // Load settings asynchronously on first access
@@ -20,7 +22,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
/// Load theme settings from SharedPreferences /// Load theme settings from SharedPreferences
Future<void> _loadFromStorage() async { Future<void> _loadFromStorage() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
final modeString = prefs.getString(kThemeModeKey); final modeString = prefs.getString(kThemeModeKey);
final useDynamic = prefs.getBool(kUseDynamicColorKey); final useDynamic = prefs.getBool(kUseDynamicColorKey);
final seedColor = prefs.getInt(kSeedColorKey); final seedColor = prefs.getInt(kSeedColorKey);
@@ -40,7 +42,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
/// Save current settings to SharedPreferences /// Save current settings to SharedPreferences
Future<void> _saveToStorage() async { Future<void> _saveToStorage() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
await prefs.setString(kThemeModeKey, state.themeMode.name); await prefs.setString(kThemeModeKey, state.themeMode.name);
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor); await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
await prefs.setInt(kSeedColorKey, state.seedColorValue); await prefs.setInt(kSeedColorKey, state.seedColorValue);
+132 -11
View File
@@ -22,9 +22,12 @@ class TrackState {
final List<ArtistAlbum>? artistAlbums; // For artist page final List<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks final List<Track>? artistTopTracks; // Artist's popular tracks
final List<SearchArtist>? searchArtists; // For search results final List<SearchArtist>? searchArtists; // For search results
final List<SearchAlbum>? searchAlbums; // For search results (albums)
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
final bool hasSearchText; // For back button handling final bool hasSearchText; // For back button handling
final bool isShowingRecentAccess; // For recent access mode final bool isShowingRecentAccess; // For recent access mode
final String? searchExtensionId; // Extension ID used for current search results final String? searchExtensionId; // Extension ID used for current search results
final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
const TrackState({ const TrackState({
this.tracks = const [], this.tracks = const [],
@@ -41,12 +44,15 @@ class TrackState {
this.artistAlbums, this.artistAlbums,
this.artistTopTracks, this.artistTopTracks,
this.searchArtists, this.searchArtists,
this.searchAlbums,
this.searchPlaylists,
this.hasSearchText = false, this.hasSearchText = false,
this.isShowingRecentAccess = false, this.isShowingRecentAccess = false,
this.searchExtensionId, this.searchExtensionId,
this.selectedSearchFilter,
}); });
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty); bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty) || (searchAlbums != null && searchAlbums!.isNotEmpty) || (searchPlaylists != null && searchPlaylists!.isNotEmpty);
TrackState copyWith({ TrackState copyWith({
List<Track>? tracks, List<Track>? tracks,
@@ -63,9 +69,13 @@ class TrackState {
List<ArtistAlbum>? artistAlbums, List<ArtistAlbum>? artistAlbums,
List<Track>? artistTopTracks, List<Track>? artistTopTracks,
List<SearchArtist>? searchArtists, List<SearchArtist>? searchArtists,
List<SearchAlbum>? searchAlbums,
List<SearchPlaylist>? searchPlaylists,
bool? hasSearchText, bool? hasSearchText,
bool? isShowingRecentAccess, bool? isShowingRecentAccess,
String? searchExtensionId, String? searchExtensionId,
String? selectedSearchFilter,
bool clearSelectedSearchFilter = false,
}) { }) {
return TrackState( return TrackState(
tracks: tracks ?? this.tracks, tracks: tracks ?? this.tracks,
@@ -82,9 +92,12 @@ class TrackState {
artistAlbums: artistAlbums ?? this.artistAlbums, artistAlbums: artistAlbums ?? this.artistAlbums,
artistTopTracks: artistTopTracks ?? this.artistTopTracks, artistTopTracks: artistTopTracks ?? this.artistTopTracks,
searchArtists: searchArtists ?? this.searchArtists, searchArtists: searchArtists ?? this.searchArtists,
searchAlbums: searchAlbums ?? this.searchAlbums,
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
hasSearchText: hasSearchText ?? this.hasSearchText, hasSearchText: hasSearchText ?? this.hasSearchText,
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess, isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
searchExtensionId: searchExtensionId, searchExtensionId: searchExtensionId,
selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter),
); );
} }
} }
@@ -127,6 +140,42 @@ class SearchArtist {
}); });
} }
class SearchAlbum {
final String id;
final String name;
final String artists;
final String? imageUrl;
final String? releaseDate;
final int totalTracks;
final String albumType;
const SearchAlbum({
required this.id,
required this.name,
required this.artists,
this.imageUrl,
this.releaseDate,
required this.totalTracks,
required this.albumType,
});
}
class SearchPlaylist {
final String id;
final String name;
final String owner;
final String? imageUrl;
final int totalTracks;
const SearchPlaylist({
required this.id,
required this.name,
required this.owner,
this.imageUrl,
required this.totalTracks,
});
}
class TrackNotifier extends Notifier<TrackState> { class TrackNotifier extends Notifier<TrackState> {
int _currentRequestId = 0; int _currentRequestId = 0;
@@ -268,10 +317,13 @@ class TrackNotifier extends Notifier<TrackState> {
} }
} }
Future<void> search(String query, {String? metadataSource}) async { Future<void> search(String query, {String? metadataSource, String? filterOverride}) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
// Preserve selected filter during loading
final currentFilter = filterOverride ?? state.selectedSearchFilter;
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); state = TrackState(isLoading: true, hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
try { try {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
@@ -289,7 +341,7 @@ class TrackNotifier extends Notifier<TrackState> {
final source = metadataSource ?? 'deezer'; final source = metadataSource ?? 'deezer';
_log.i( _log.i(
'Search started: source=$source, query="$query", useExtensions=$useExtensions', 'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
); );
Map<String, dynamic> results; Map<String, dynamic> results;
@@ -315,11 +367,11 @@ class TrackNotifier extends Notifier<TrackState> {
if (source == 'deezer') { if (source == 'deezer') {
_log.d('Calling Deezer search API...'); _log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5); 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'); _log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums');
} else { } else {
_log.d('Calling Spotify search API...'); _log.d('Calling Spotify search API...');
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5); 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'); _log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
} }
@@ -330,8 +382,9 @@ class TrackNotifier extends Notifier<TrackState> {
final trackList = results['tracks'] as List<dynamic>? ?? []; final trackList = results['tracks'] as List<dynamic>? ?? [];
final artistList = results['artists'] as List<dynamic>? ?? []; final artistList = results['artists'] as List<dynamic>? ?? [];
final albumList = results['albums'] as List<dynamic>? ?? [];
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists'); _log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums');
final tracks = <Track>[]; final tracks = <Track>[];
@@ -373,25 +426,61 @@ class TrackNotifier extends Notifier<TrackState> {
} }
} }
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists parsed successfully'); final albums = <SearchAlbum>[];
for (int i = 0; i < albumList.length; i++) {
final a = albumList[i];
try {
if (a is Map<String, dynamic>) {
albums.add(_parseSearchAlbum(a));
} else {
_log.w('Album[$i] is not a Map: ${a.runtimeType}');
}
} catch (e) {
_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++) {
final p = playlistList[i];
try {
if (p is Map<String, dynamic>) {
playlists.add(_parseSearchPlaylist(p));
} else {
_log.w('Playlist[$i] is not a Map: ${p.runtimeType}');
}
} catch (e) {
_log.e('Failed to parse playlist[$i]: $e', e);
}
}
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully');
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
searchArtists: artists, searchArtists: artists,
searchAlbums: albums,
searchPlaylists: playlists,
isLoading: false, isLoading: false,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
selectedSearchFilter: currentFilter, // Preserve filter in results
); );
} catch (e, stackTrace) { } catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
_log.e('Search failed: $e', e, stackTrace); _log.e('Search failed: $e', e, stackTrace);
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText); state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
} }
} }
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async { Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); state = TrackState(
isLoading: true,
hasSearchText: state.hasSearchText,
selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading
);
try { try {
_log.i('Custom search started: extension=$extensionId, query="$query"'); _log.i('Custom search started: extension=$extensionId, query="$query"');
@@ -423,6 +512,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
searchExtensionId: extensionId, // Store which extension was used searchExtensionId: extensionId, // Store which extension was used
selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter
); );
} catch (e, stackTrace) { } catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
@@ -474,6 +564,15 @@ class TrackNotifier extends Notifier<TrackState> {
state = const TrackState(); state = const TrackState();
} }
/// Set selected search filter for extension search
void setSearchFilter(String? filter) {
if (state.selectedSearchFilter == filter) return;
state = state.copyWith(
selectedSearchFilter: filter,
clearSelectedSearchFilter: filter == null,
);
}
/// Set search text state for back button handling /// Set search text state for back button handling
void setSearchText(bool hasText) { void setSearchText(bool hasText) {
if (state.hasSearchText == hasText) { if (state.hasSearchText == hasText) {
@@ -571,6 +670,28 @@ class TrackNotifier extends Notifier<TrackState> {
); );
} }
SearchAlbum _parseSearchAlbum(Map<String, dynamic> data) {
return SearchAlbum(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
artists: data['artists'] as String? ?? '',
imageUrl: data['images'] as String?,
releaseDate: data['release_date'] as String?,
totalTracks: data['total_tracks'] as int? ?? 0,
albumType: data['album_type'] as String? ?? 'album',
);
}
SearchPlaylist _parseSearchPlaylist(Map<String, dynamic> data) {
return SearchPlaylist(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
owner: data['owner'] as String? ?? '',
imageUrl: data['images'] as String?,
totalTracks: data['total_tracks'] as int? ?? 0,
);
}
void _preWarmCacheForTracks(List<Track> tracks) { void _preWarmCacheForTracks(List<Track> tracks) {
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList(); final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
if (tracksWithIsrc.isEmpty) return; if (tracksWithIsrc.isEmpty) return;
+10 -3
View File
@@ -80,7 +80,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'; // Use extensionId if available, otherwise detect from albumId prefix
final providerId = widget.extensionId ??
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
ref.read(recentAccessProvider.notifier).recordAlbumAccess( ref.read(recentAccessProvider.notifier).recordAlbumAccess(
id: widget.albumId, id: widget.albumId,
name: widget.albumName, name: widget.albumName,
@@ -90,10 +92,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
); );
}); });
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId); // 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; // Use provided artist ID if available
if (_tracks == null) { if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks(); _fetchTracks();
} }
+761 -186
View File
File diff suppressed because it is too large Load Diff
+123 -11
View File
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/palette_service.dart'; import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
@@ -15,12 +16,14 @@ class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName; final String playlistName;
final String? coverUrl; final String? coverUrl;
final List<Track> tracks; final List<Track> tracks;
final String? playlistId; // Deezer playlist ID for fetching tracks
const PlaylistScreen({ const PlaylistScreen({
super.key, super.key,
required this.playlistName, required this.playlistName,
this.coverUrl, this.coverUrl,
required this.tracks, required this.tracks,
this.playlistId,
}); });
@override @override
@@ -31,12 +34,18 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Color? _dominantColor; Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
List<Track>? _fetchedTracks;
bool _isLoading = false;
String? _error;
List<Track> get _tracks => _fetchedTracks ?? widget.tracks;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
_extractDominantColor(); _extractDominantColor();
_fetchTracksIfNeeded();
} }
@override @override
@@ -46,6 +55,65 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
super.dispose(); super.dispose();
} }
Future<void> _fetchTracksIfNeeded() async {
if (widget.tracks.isNotEmpty || widget.playlistId == null) return;
setState(() {
_isLoading = true;
_error = null;
});
try {
// Extract numeric ID from "deezer:123" format
String playlistId = widget.playlistId!;
if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7);
}
final result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
if (!mounted) return;
// Go backend returns 'track_list' not 'tracks'
final trackList = result['track_list'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
setState(() {
_fetchedTracks = tracks;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Track _parseTrack(Map<String, dynamic> data) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
);
}
void _onScroll() { void _onScroll() {
final shouldShow = _scrollController.offset > 280; final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) { if (shouldShow != _showTitleInAppBar) {
@@ -211,15 +279,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
children: [ children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer), Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(context.l10n.tracksCount(widget.tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), Text(context.l10n.tracksCount(_tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
], ],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
onPressed: () => _downloadAll(context), onPressed: _tracks.isEmpty ? null : () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18), icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(widget.tracks.length)), label: Text(context.l10n.downloadAllCount(_tracks.length)),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48), minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
@@ -249,10 +317,54 @@ const SizedBox(height: 16),
} }
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) { Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
if (_isLoading) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
);
}
if (_error != null) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
color: colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(child: Text(_error!, style: TextStyle(color: colorScheme.error))),
],
),
),
),
),
);
}
if (_tracks.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
context.l10n.errorNoTracksFound,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
),
);
}
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final track = widget.tracks[index]; final track = _tracks[index];
return KeyedSubtree( return KeyedSubtree(
key: ValueKey(track.id), key: ValueKey(track.id),
child: _PlaylistTrackItem( child: _PlaylistTrackItem(
@@ -261,7 +373,7 @@ const SizedBox(height: 16),
), ),
); );
}, },
childCount: widget.tracks.length, childCount: _tracks.length,
), ),
); );
} }
@@ -286,21 +398,21 @@ const SizedBox(height: 16),
} }
void _downloadAll(BuildContext context) { void _downloadAll(BuildContext context) {
if (widget.tracks.isEmpty) return; if (_tracks.isEmpty) return;
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) { if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show( DownloadServicePicker.show(
context, context,
trackName: '${widget.tracks.length} tracks', trackName: '${_tracks.length} tracks',
artistName: widget.playlistName, artistName: widget.playlistName,
onSelect: (quality, service) { onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, service, qualityOverride: quality); ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length)))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length))));
}, },
); );
} else { } else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, settings.defaultService); ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length)))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length))));
} }
} }
} }
+159 -8
View File
@@ -366,6 +366,21 @@ final albumKey =
}); });
} }
/// Get short badge text for quality display
String _getQualityBadgeText(String quality) {
// For lossless: "24-bit/96kHz" -> "24-bit"
if (quality.contains('bit')) {
return quality.split('/').first;
}
// For lossy: "OPUS 128kbps" -> "128k", "MP3 320kbps" -> "320k"
final bitrateMatch = RegExp(r'(\d+)kbps').firstMatch(quality);
if (bitrateMatch != null) {
return '${bitrateMatch.group(1)}k';
}
// Fallback: return format name
return quality.split(' ').first;
}
Future<void> _deleteSelected() async { Future<void> _deleteSelected() async {
final count = _selectedIds.length; final count = _selectedIds.length;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@@ -783,11 +798,21 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Text( child: Row(
'Downloading (${queueItems.length})', children: [
style: Theme.of(context).textTheme.titleMedium?.copyWith( Text(
fontWeight: FontWeight.bold, 'Downloading (${queueItems.length})',
), style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
_buildExportFailedButton(context, ref, colorScheme),
const SizedBox(width: 4),
_buildPauseResumeButton(context, ref, colorScheme),
const SizedBox(width: 4),
_buildClearAllButton(context, ref, colorScheme),
],
), ),
), ),
), ),
@@ -1146,6 +1171,132 @@ if (queueItems.isEmpty &&
); );
} }
Widget _buildPauseResumeButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
return TextButton.icon(
onPressed: () {
ref.read(downloadQueueProvider.notifier).togglePause();
},
icon: Icon(
isPaused ? Icons.play_arrow : Icons.pause,
size: 18,
),
label: Text(
isPaused ? context.l10n.actionResume : context.l10n.actionPause,
),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: isPaused ? colorScheme.primary : colorScheme.onSurfaceVariant,
),
);
}
Widget _buildClearAllButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
return TextButton.icon(
onPressed: () => _showClearAllDialog(context, ref, colorScheme),
icon: const Icon(Icons.clear_all, size: 18),
label: Text(context.l10n.queueClearAll),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: colorScheme.error,
),
);
}
Widget _buildExportFailedButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
final queueState = ref.watch(downloadQueueProvider);
final failedCount = queueState.failedCount;
if (failedCount == 0) {
return const SizedBox.shrink();
}
return TextButton.icon(
onPressed: () => _exportFailedDownloads(context, ref),
icon: const Icon(Icons.file_download, size: 18),
label: Text(context.l10n.queueExportFailed),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: colorScheme.tertiary,
),
);
}
Future<void> _exportFailedDownloads(
BuildContext context,
WidgetRef ref,
) async {
final filePath = await ref.read(downloadQueueProvider.notifier).exportFailedDownloads();
if (!context.mounted) return;
if (filePath != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.queueExportFailedSuccess),
action: SnackBarAction(
label: context.l10n.queueExportFailedClear,
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearFailedDownloads();
},
),
duration: const Duration(seconds: 5),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.queueExportFailedError),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
Future<void> _showClearAllDialog(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.queueClearAll),
content: Text(context.l10n.queueClearAllMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
),
child: Text(context.l10n.dialogClear),
),
],
),
);
if (confirmed == true && context.mounted) {
ref.read(downloadQueueProvider.notifier).clearAll();
}
}
Widget _buildEmptyState( Widget _buildEmptyState(
BuildContext context, BuildContext context,
ColorScheme colorScheme, ColorScheme colorScheme,
@@ -1692,7 +1843,7 @@ child: CachedNetworkImage(
), ),
), ),
), ),
if (item.quality != null && item.quality!.contains('bit')) if (item.quality != null && item.quality!.isNotEmpty)
Positioned( Positioned(
left: 4, left: 4,
top: 4, top: 4,
@@ -1708,7 +1859,7 @@ child: CachedNetworkImage(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text( child: Text(
item.quality!.split('/').first, _getQualityBadgeText(item.quality!),
style: Theme.of(context).textTheme.labelSmall style: Theme.of(context).textTheme.labelSmall
?.copyWith( ?.copyWith(
color: item.quality!.startsWith('24') color: item.quality!.startsWith('24')
@@ -1943,7 +2094,7 @@ child: CachedNetworkImage(
), ),
), ),
if (item.quality != null && if (item.quality != null &&
item.quality!.contains('bit')) ...[ item.quality!.isNotEmpty) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
+24 -7
View File
@@ -112,11 +112,10 @@ class AboutPage extends StatelessWidget {
githubUsername: 'sachinsenal0x64', githubUsername: 'sachinsenal0x64',
showDivider: true, showDivider: true,
), ),
_AboutSettingsItem( _ContributorItem(
icon: Icons.cloud_outlined, name: 'sjdonado',
title: context.l10n.aboutDoubleDouble, description: context.l10n.aboutSjdonadoDesc,
subtitle: context.l10n.aboutDoubleDoubleDesc, githubUsername: 'sjdonado',
onTap: () => _launchUrl('https://doubledouble.top'),
showDivider: true, showDivider: true,
), ),
_AboutSettingsItem( _AboutSettingsItem(
@@ -185,7 +184,7 @@ _AboutSettingsItem(
icon: Icons.forum_outlined, icon: Icons.forum_outlined,
title: context.l10n.aboutTelegramChat, title: context.l10n.aboutTelegramChat,
subtitle: context.l10n.aboutTelegramChatSubtitle, subtitle: context.l10n.aboutTelegramChatSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflacchat'), onTap: () => _launchUrl('https://t.me/spotiflac_chat'),
showDivider: false, showDivider: false,
), ),
], ],
@@ -467,11 +466,29 @@ class _TranslatorsSection extends StatelessWidget {
flag: '🇷🇺', flag: '🇷🇺',
), ),
_Translator( _Translator(
name: 'Max', name: 'Amonoman',
crowdinUsername: 'amonoman', crowdinUsername: 'amonoman',
language: 'German', language: 'German',
flag: '🇩🇪', flag: '🇩🇪',
), ),
_Translator(
name: 'Re*Index.(ot_inc)',
crowdinUsername: 'ot_inc',
language: 'Japanese',
flag: '🇯🇵',
),
_Translator(
name: 'Kaan',
crowdinUsername: 'glai',
language: 'Turkish',
flag: '🇹🇷',
),
_Translator(
name: 'BedirhanGltkn',
crowdinUsername: 'bedirhangltkn',
language: 'Turkish',
flag: '🇹🇷',
),
]; ];
@override @override
+271 -22
View File
@@ -3,23 +3,98 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerWidget { class DownloadSettingsPage extends ConsumerStatefulWidget {
const DownloadSettingsPage({super.key}); const DownloadSettingsPage({super.key});
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<DownloadSettingsPage> createState() => _DownloadSettingsPageState();
}
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false;
@override
void initState() {
super.initState();
_initDeviceInfo();
}
Future<void> _initDeviceInfo() async {
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
final sdkVersion = androidInfo.version.sdkInt;
final hasAccess = await Permission.manageExternalStorage.isGranted;
if (mounted) {
setState(() {
_androidSdkVersion = sdkVersion;
_hasAllFilesAccess = hasAccess;
});
}
}
}
Future<void> _requestAllFilesAccess() async {
final status = await Permission.manageExternalStorage.request();
if (status.isGranted) {
ref.read(settingsProvider.notifier).setUseAllFilesAccess(true);
if (mounted) {
setState(() => _hasAllFilesAccess = true);
}
} else if (status.isPermanentlyDenied) {
if (mounted) {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.setupStorageAccessRequired),
content: Text(context.l10n.allFilesAccessDeniedMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.setupOpenSettings),
),
],
),
);
if (shouldOpen == true) {
await openAppSettings();
}
}
}
}
Future<void> _disableAllFilesAccess() async {
ref.read(settingsProvider.notifier).setUseAllFilesAccess(false);
// Note: We can't revoke the permission programmatically,
// but we can stop using it in the app
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.allFilesAccessDisabledMessage)),
);
}
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
final isBuiltInService = _builtInServices.contains(settings.defaultService); final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal';
return PopScope( return PopScope(
canPop: true, canPop: true,
@@ -99,17 +174,6 @@ class DownloadSettingsPage extends ConsumerWidget {
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setAskQualityBeforeDownload(value), .setAskQualityBeforeDownload(value),
), ),
SettingsSwitchItem(
icon: Icons.audiotrack,
title: context.l10n.enableMp3Option,
subtitle: settings.enableMp3Option
? context.l10n.enableMp3OptionSubtitleOn
: context.l10n.enableMp3OptionSubtitleOff,
value: settings.enableMp3Option,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setEnableMp3Option(value),
),
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[ if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
_QualityOption( _QualityOption(
title: context.l10n.qualityFlacLossless, title: context.l10n.qualityFlacLossless,
@@ -134,16 +198,25 @@ class DownloadSettingsPage extends ConsumerWidget {
onTap: () => ref onTap: () => ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setAudioQuality('HI_RES_LOSSLESS'), .setAudioQuality('HI_RES_LOSSLESS'),
showDivider: settings.enableMp3Option, showDivider: isTidalService,
), ),
if (settings.enableMp3Option) // Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus
if (isTidalService)
_QualityOption( _QualityOption(
title: context.l10n.qualityMp3, title: 'Lossy 320kbps',
subtitle: context.l10n.qualityMp3Subtitle, subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
isSelected: settings.audioQuality == 'MP3', isSelected: settings.audioQuality == 'HIGH',
onTap: () => ref onTap: () => ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setAudioQuality('MP3'), .setAudioQuality('HIGH'),
showDivider: false,
),
if (isTidalService && settings.audioQuality == 'HIGH')
SettingsItem(
icon: Icons.tune,
title: 'Lossy Format',
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
onTap: () => _showTidalHighFormatPicker(context, ref, settings.tidalHighFormat),
showDivider: false, showDivider: false,
), ),
], ],
@@ -261,6 +334,79 @@ class DownloadSettingsPage extends ConsumerWidget {
), ),
), ),
// All Files Access section (Android 13+ only)
if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionStorageAccess),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.folder_special_outlined,
title: context.l10n.allFilesAccess,
subtitle: _hasAllFilesAccess
? context.l10n.allFilesAccessEnabledSubtitle
: context.l10n.allFilesAccessDisabledSubtitle,
value: _hasAllFilesAccess && settings.useAllFilesAccess,
onChanged: (value) {
if (value) {
_requestAllFilesAccess();
} else {
_disableAllFilesAccess();
}
},
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.allFilesAccessDescription,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
),
],
const SliverToBoxAdapter(child: SizedBox(height: 16)),
// Auto Export Failed Downloads
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.file_download_outlined,
title: context.l10n.settingsAutoExportFailed,
subtitle: context.l10n.settingsAutoExportFailedSubtitle,
value: settings.autoExportFailedDownloads,
onChanged: (value) {
ref.read(settingsProvider.notifier).setAutoExportFailedDownloads(value);
},
showDivider: false,
),
],
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)), const SliverToBoxAdapter(child: SizedBox(height: 32)),
], ],
), ),
@@ -276,6 +422,8 @@ class DownloadSettingsPage extends ConsumerWidget {
return 'Albums/Artist/[Year] Album/'; return 'Albums/Artist/[Year] Album/';
case 'year_album': case 'year_album':
return 'Albums/[Year] Album/'; return 'Albums/[Year] Album/';
case 'artist_album_singles':
return 'Artist/Album/ + Artist/Singles/';
default: default:
return 'Albums/Artist/Album Name/'; return 'Albums/Artist/Album Name/';
} }
@@ -328,6 +476,16 @@ class DownloadSettingsPage extends ConsumerWidget {
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
ListTile(
leading: const Icon(Icons.person_outlined),
title: Text(context.l10n.albumFolderArtistAlbumSingles),
subtitle: Text(context.l10n.albumFolderArtistAlbumSinglesSubtitle),
trailing: current == 'artist_album_singles' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album_singles');
Navigator.pop(context);
},
),
], ],
), ),
), ),
@@ -564,7 +722,7 @@ class DownloadSettingsPage extends ConsumerWidget {
if (ctx.mounted) Navigator.pop(ctx); if (ctx.mounted) Navigator.pop(ctx);
}, },
), ),
ListTile( ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant), leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: Text(context.l10n.setupChooseFromFiles), title: Text(context.l10n.setupChooseFromFiles),
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle), subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
@@ -573,6 +731,24 @@ class DownloadSettingsPage extends ConsumerWidget {
// Note: iOS requires folder to have at least one file to be selectable // Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath(); final result = await FilePicker.platform.getDirectoryPath();
if (result != null) { if (result != null) {
// iOS: Check if user selected iCloud Drive (not accessible by Go backend)
if (Platform.isIOS) {
final isICloudPath = result.contains('Mobile Documents') ||
result.contains('CloudDocs') ||
result.contains('com~apple~CloudDocs');
if (isICloudPath) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(context.l10n.setupIcloudNotSupported),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
}
ref ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setDownloadDirectory(result); .setDownloadDirectory(result);
@@ -710,6 +886,79 @@ class DownloadSettingsPage extends ConsumerWidget {
); );
} }
String _getTidalHighFormatLabel(String format) {
switch (format) {
case 'mp3_320':
return 'MP3 320kbps';
case 'opus_128':
return 'Opus 128kbps';
default:
return 'MP3 320kbps';
}
}
void _showTidalHighFormatPicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Lossy 320kbps Format',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('MP3 320kbps'),
subtitle: const Text('Best compatibility, ~10MB per track'),
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Opus 128kbps'),
subtitle: const Text('Modern codec, ~4MB per track'),
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showFolderOrganizationPicker( void _showFolderOrganizationPicker(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
+12 -4
View File
@@ -19,6 +19,14 @@ class ExtensionsPage extends ConsumerStatefulWidget {
} }
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> { class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
static final RegExp _platformExceptionPattern =
RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),');
static final RegExp _platformExceptionSimplePattern =
RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null');
static final RegExp _trailingNullsPattern =
RegExp(r',\s*null\s*,\s*null\)?$');
static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -296,19 +304,19 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
String message = error; String message = error;
if (message.contains('PlatformException')) { if (message.contains('PlatformException')) {
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message); final match = _platformExceptionPattern.firstMatch(message);
if (match != null) { if (match != null) {
message = match.group(1)?.trim() ?? message; message = match.group(1)?.trim() ?? message;
} else { } else {
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message); final simpleMatch = _platformExceptionSimplePattern.firstMatch(message);
if (simpleMatch != null) { if (simpleMatch != null) {
message = simpleMatch.group(1)?.trim() ?? message; message = simpleMatch.group(1)?.trim() ?? message;
} }
} }
} }
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), ''); message = message.replaceAll(_trailingNullsPattern, '');
message = message.replaceAll(RegExp(r'^\s*,\s*'), ''); message = message.replaceAll(_leadingCommaPattern, '');
return message; return message;
} }
+5 -1
View File
@@ -5,6 +5,9 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
final RegExp _domainPattern =
RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false);
class LogScreen extends StatefulWidget { class LogScreen extends StatefulWidget {
const LogScreen({super.key}); const LogScreen({super.key});
@@ -13,6 +16,7 @@ class LogScreen extends StatefulWidget {
} }
class _LogScreenState extends State<LogScreen> { class _LogScreenState extends State<LogScreen> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
String _selectedLevel = 'ALL'; String _selectedLevel = 'ALL';
@@ -633,7 +637,7 @@ class _LogSummaryCard extends StatelessWidget {
combined.contains('connection refused')) { combined.contains('connection refused')) {
hasISPBlocking = true; hasISPBlocking = true;
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined); final domainMatch = _domainPattern.firstMatch(combined);
if (domainMatch != null) { if (domainMatch != null) {
blockedDomains.add(domainMatch.group(1)!); blockedDomains.add(domainMatch.group(1)!);
} }
@@ -153,14 +153,6 @@ class OptionsSettingsPage extends ConsumerWidget {
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setUseExtensionProviders(v), .setUseExtensionProviders(v),
), ),
SettingsSwitchItem(
icon: Icons.lyrics,
title: context.l10n.optionsEmbedLyrics,
subtitle: context.l10n.optionsEmbedLyricsSubtitle,
value: settings.embedLyrics,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setEmbedLyrics(v),
),
SettingsSwitchItem( SettingsSwitchItem(
icon: Icons.image, icon: Icons.image,
title: context.l10n.optionsMaxQualityCover, title: context.l10n.optionsMaxQualityCover,
+14 -37
View File
@@ -67,10 +67,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
bool storageGranted = false; bool storageGranted = false;
if (_androidSdkVersion >= 33) { if (_androidSdkVersion >= 33) {
final manageStatus = await Permission.manageExternalStorage.status; // Android 13+: Only require READ_MEDIA_AUDIO by default
// MANAGE_EXTERNAL_STORAGE is optional and can be enabled in settings
final audioStatus = await Permission.audio.status; final audioStatus = await Permission.audio.status;
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus'); debugPrint('[Permission] Android 13+ check: READ_MEDIA_AUDIO=$audioStatus');
storageGranted = manageStatus.isGranted && audioStatus.isGranted; storageGranted = audioStatus.isGranted;
} else if (_androidSdkVersion >= 30) { } else if (_androidSdkVersion >= 30) {
final manageStatus = await Permission.manageExternalStorage.status; final manageStatus = await Permission.manageExternalStorage.status;
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus'); debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
@@ -108,44 +109,20 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
bool allGranted = false; bool allGranted = false;
if (_androidSdkVersion >= 33) { if (_androidSdkVersion >= 33) {
var manageStatus = await Permission.manageExternalStorage.status; // Android 13+: Only request READ_MEDIA_AUDIO by default
if (!manageStatus.isGranted) { // MANAGE_EXTERNAL_STORAGE is optional (can be enabled in Settings)
if (mounted) {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.setupStorageAccessRequired),
content: Text(
'${context.l10n.setupStorageAccessMessage}\n\n'
'${context.l10n.setupAllowAccessToManageFiles}',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.setupOpenSettings),
),
],
),
);
if (shouldOpen == true) {
await Permission.manageExternalStorage.request();
await Future.delayed(const Duration(milliseconds: 500));
manageStatus = await Permission.manageExternalStorage.status;
}
}
}
var audioStatus = await Permission.audio.status; var audioStatus = await Permission.audio.status;
if (!audioStatus.isGranted && manageStatus.isGranted) { if (!audioStatus.isGranted) {
audioStatus = await Permission.audio.request(); audioStatus = await Permission.audio.request();
} }
allGranted = manageStatus.isGranted && audioStatus.isGranted; allGranted = audioStatus.isGranted;
if (audioStatus.isPermanentlyDenied) {
_showPermissionDeniedDialog('Audio');
setState(() => _isLoading = false);
return;
}
} else if (_androidSdkVersion >= 30) { } else if (_androidSdkVersion >= 30) {
var manageStatus = await Permission.manageExternalStorage.status; var manageStatus = await Permission.manageExternalStorage.status;
+158 -18
View File
@@ -25,14 +25,20 @@ class TrackMetadataScreen extends ConsumerStatefulWidget {
class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> { class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _fileExists = false; bool _fileExists = false;
int? _fileSize; int? _fileSize;
String? _lyrics; String? _lyrics; // Cleaned lyrics for display (no timestamps)
String? _rawLyrics; // Raw LRC with timestamps for embedding
bool _lyricsLoading = false; bool _lyricsLoading = false;
String? _lyricsError; String? _lyricsError;
Color? _dominantColor; Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
bool _isEmbedding = false; // Track embed operation in progress
bool _isInstrumental = false; // Track if detected as instrumental
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
static final RegExp _lrcTimestampPattern = static final RegExp _lrcTimestampPattern =
RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
static final RegExp _lrcMetadataPattern =
RegExp(r'^\[[a-zA-Z]+:.*\]$');
static const List<String> _months = [ static const List<String> _months = [
'Jan', 'Jan',
'Feb', 'Feb',
@@ -511,16 +517,27 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Determine audio quality string based on file type // Determine audio quality string - prefer stored quality from download
String? audioQualityStr; String? audioQualityStr;
final fileName = item.filePath.split('/').last; final fileName = item.filePath.split('/').last;
final fileExt = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : ''; final fileExt = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : '';
if (fileExt == 'MP3') { // Use stored quality from download history if available
audioQualityStr = '320kbps'; if (item.quality != null && item.quality!.isNotEmpty) {
audioQualityStr = item.quality;
} else if (bitDepth != null && sampleRate != null) { } else if (bitDepth != null && sampleRate != null) {
// Fallback for FLAC files without stored quality
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1); final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz'; audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
} else {
// Fallback based on file extension for legacy items
if (fileExt == 'MP3') {
audioQualityStr = 'MP3';
} else if (fileExt == 'OPUS' || fileExt == 'OGG') {
audioQualityStr = 'Opus';
} else if (fileExt == 'M4A' || fileExt == 'AAC') {
audioQualityStr = 'AAC';
}
} }
final items = <_MetadataItem>[ final items = <_MetadataItem>[
@@ -844,18 +861,62 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
], ],
), ),
) )
else if (_lyrics != null) else if (_isInstrumental)
Container( Container(
constraints: const BoxConstraints(maxHeight: 300), padding: const EdgeInsets.all(16),
child: SingleChildScrollView( decoration: BoxDecoration(
child: Text( color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
_lyrics!, borderRadius: BorderRadius.circular(12),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( ),
color: colorScheme.onSurface, child: Row(
height: 1.6, mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.music_note, color: colorScheme.tertiary, size: 20),
const SizedBox(width: 12),
Text(
context.l10n.trackInstrumental,
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontStyle: FontStyle.italic,
),
),
],
),
)
else if (_lyrics != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
constraints: const BoxConstraints(maxHeight: 300),
child: SingleChildScrollView(
child: Text(
_lyrics!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
height: 1.6,
),
),
), ),
), ),
), // Show "Embed Lyrics" button if lyrics are from online (not already embedded)
if (!_lyricsEmbedded && _fileExists) ...[
const SizedBox(height: 16),
Center(
child: FilledButton.tonalIcon(
onPressed: _isEmbedding ? null : _embedLyrics,
icon: _isEmbedding
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_alt),
label: Text(context.l10n.trackEmbedLyrics),
),
),
],
],
) )
else else
Center( Center(
@@ -877,26 +938,57 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
setState(() { setState(() {
_lyricsLoading = true; _lyricsLoading = true;
_lyricsError = null; _lyricsError = null;
_isInstrumental = false;
}); });
try { try {
// Convert duration from seconds to milliseconds // Convert duration from seconds to milliseconds
final durationMs = (item.duration ?? 0) * 1000; final durationMs = (item.duration ?? 0) * 1000;
// Add timeout to prevent infinite loading // First, check if lyrics are embedded in the file
if (_fileExists) {
final embeddedResult = await PlatformBridge.getLyricsLRC(
'',
item.trackName,
item.artistName,
filePath: cleanFilePath,
durationMs: 0,
).timeout(const Duration(seconds: 5), onTimeout: () => '');
if (embeddedResult.isNotEmpty) {
// Lyrics found in file
if (mounted) {
final cleanLyrics = _cleanLrcForDisplay(embeddedResult);
setState(() {
_lyrics = cleanLyrics;
_lyricsEmbedded = true;
_lyricsLoading = false;
});
}
return;
}
}
// No embedded lyrics, fetch from online
final result = await PlatformBridge.getLyricsLRC( final result = await PlatformBridge.getLyricsLRC(
item.spotifyId ?? '', item.spotifyId ?? '',
item.trackName, item.trackName,
item.artistName, item.artistName,
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first filePath: null, // Don't check file again
durationMs: durationMs, durationMs: durationMs,
).timeout( ).timeout(
const Duration(seconds: 20), const Duration(seconds: 20),
onTimeout: () => '', // Return empty string on timeout onTimeout: () => '',
); );
if (mounted) { if (mounted) {
if (result.isEmpty) { // Check for instrumental marker
if (result == '[instrumental:true]') {
setState(() {
_isInstrumental = true;
_lyricsLoading = false;
});
} else if (result.isEmpty) {
setState(() { setState(() {
_lyricsError = context.l10n.trackLyricsNotAvailable; _lyricsError = context.l10n.trackLyricsNotAvailable;
_lyricsLoading = false; _lyricsLoading = false;
@@ -905,6 +997,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final cleanLyrics = _cleanLrcForDisplay(result); final cleanLyrics = _cleanLrcForDisplay(result);
setState(() { setState(() {
_lyrics = cleanLyrics; _lyrics = cleanLyrics;
_rawLyrics = result; // Keep raw LRC with timestamps for embedding
_lyricsEmbedded = false; // Lyrics from online, not embedded
_lyricsLoading = false; _lyricsLoading = false;
}); });
} }
@@ -921,13 +1015,59 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
} }
} }
Future<void> _embedLyrics() async {
if (_isEmbedding || _rawLyrics == null || !_fileExists) return;
setState(() => _isEmbedding = true);
try {
// Use raw LRC content directly - it already has timestamps and metadata
final result = await PlatformBridge.embedLyricsToFile(
cleanFilePath,
_rawLyrics!,
);
if (mounted) {
if (result['success'] == true) {
setState(() {
_lyricsEmbedded = true;
_isEmbedding = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackLyricsEmbedded)),
);
} else {
setState(() => _isEmbedding = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result['error'] ?? 'Failed to embed lyrics')),
);
}
}
} catch (e) {
if (mounted) {
setState(() => _isEmbedding = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
}
String _cleanLrcForDisplay(String lrc) { String _cleanLrcForDisplay(String lrc) {
final lines = lrc.split('\n'); final lines = lrc.split('\n');
final cleanLines = <String>[]; final cleanLines = <String>[];
for (final line in lines) { for (final line in lines) {
final cleanLine = line.replaceAll(_lrcTimestampPattern, '').trim(); final trimmedLine = line.trim();
// Skip metadata tags
if (_lrcMetadataPattern.hasMatch(trimmedLine)) {
continue;
}
// Remove timestamp and clean up
final cleanLine = trimmedLine.replaceAll(_lrcTimestampPattern, '').trim();
if (cleanLine.isNotEmpty) { if (cleanLine.isNotEmpty) {
cleanLines.add(cleanLine); cleanLines.add(cleanLine);
} }
+2 -1
View File
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/utils/logger.dart';
class CsvImportService { class CsvImportService {
static final _log = AppLogger('CsvImportService'); static final _log = AppLogger('CsvImportService');
static final RegExp _lineSplitPattern = RegExp(r'\r\n|\r|\n');
static Future<List<Track>> pickAndParseCsv({ static Future<List<Track>> pickAndParseCsv({
void Function(int current, int total)? onProgress, void Function(int current, int total)? onProgress,
@@ -123,7 +124,7 @@ class CsvImportService {
static List<Track> _parseCsv(String content) { static List<Track> _parseCsv(String content) {
final List<Track> tracks = []; final List<Track> tracks = [];
final lines = content.split(RegExp(r'\r\n|\r|\n')); final lines = content.split(_lineSplitPattern);
if (lines.isEmpty) return tracks; if (lines.isEmpty) return tracks;
int startIdx = 0; int startIdx = 0;
+276 -16
View File
@@ -1,23 +1,25 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/services.dart'; import 'dart:typed_data';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg'); final _log = AppLogger('FFmpeg');
/// FFmpeg service for audio conversion and remuxing
/// Uses native MethodChannel to call FFmpegKit from local AAR
class FFmpegService { class FFmpegService {
static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg');
static Future<FFmpegResult> _execute(String command) async { static Future<FFmpegResult> _execute(String command) async {
try { try {
final result = await _channel.invokeMethod('execute', {'command': command}); final session = await FFmpegKit.execute(command);
final map = Map<String, dynamic>.from(result); final returnCode = await session.getReturnCode();
final output = await session.getOutput() ?? '';
return FFmpegResult( return FFmpegResult(
success: map['success'] as bool, success: ReturnCode.isSuccess(returnCode),
returnCode: map['returnCode'] as int, returnCode: returnCode?.getValue() ?? -1,
output: map['output'] as String, output: output,
); );
} catch (e) { } catch (e) {
_log.e('FFmpeg execute error: $e'); _log.e('FFmpeg execute error: $e');
@@ -44,6 +46,47 @@ class FFmpegService {
return null; return null;
} }
static Future<String?> convertM4aToLossy(
String inputPath, {
required String format,
String? bitrate,
bool deleteOriginal = true,
}) async {
String bitrateValue = format == 'opus' ? '128k' : '320k';
if (bitrate != null && bitrate.contains('_')) {
final parts = bitrate.split('_');
if (parts.length == 2) {
bitrateValue = '${parts[1]}k';
}
}
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = inputPath.replaceAll('.m4a', extension);
String command;
if (format == 'opus') {
command =
'-i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
} else {
command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y';
}
final result = await _execute(command);
if (result.success) {
if (deleteOriginal) {
try {
await File(inputPath).delete();
} catch (_) {}
}
return outputPath;
}
_log.e('M4A to $format conversion failed: ${result.output}');
return null;
}
static Future<String?> convertFlacToMp3( static Future<String?> convertFlacToMp3(
String inputPath, { String inputPath, {
String bitrate = '320k', String bitrate = '320k',
@@ -69,6 +112,56 @@ class FFmpegService {
return null; return null;
} }
static Future<String?> convertFlacToOpus(
String inputPath, {
String bitrate = '128k',
bool deleteOriginal = true,
}) async {
final outputPath = inputPath.replaceAll('.flac', '.opus');
final command =
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y';
final result = await _execute(command);
if (result.success) {
if (deleteOriginal) {
try {
await File(inputPath).delete();
} catch (_) {}
}
return outputPath;
}
_log.e('FLAC to Opus conversion failed: ${result.output}');
return null;
}
static Future<String?> convertFlacToLossy(
String inputPath, {
required String format,
String? bitrate,
bool deleteOriginal = true,
}) async {
String bitrateValue = '320k';
if (bitrate != null && bitrate.contains('_')) {
final parts = bitrate.split('_');
if (parts.length == 2) {
bitrateValue = '${parts[1]}k';
}
}
switch (format.toLowerCase()) {
case 'opus':
final opusBitrate = bitrate?.startsWith('opus_') == true ? bitrateValue : '128k';
return convertFlacToOpus(inputPath, bitrate: opusBitrate, deleteOriginal: deleteOriginal);
case 'mp3':
default:
final mp3Bitrate = bitrate?.startsWith('mp3_') == true ? bitrateValue : '320k';
return convertFlacToMp3(inputPath, bitrate: mp3Bitrate, deleteOriginal: deleteOriginal);
}
}
static Future<String?> convertFlacToM4a( static Future<String?> convertFlacToM4a(
String inputPath, { String inputPath, {
String codec = 'aac', String codec = 'aac',
@@ -104,8 +197,8 @@ class FFmpegService {
static Future<bool> isAvailable() async { static Future<bool> isAvailable() async {
try { try {
final version = await _channel.invokeMethod('getVersion'); final version = await FFmpegKitConfig.getFFmpegVersion();
return version != null && version.toString().isNotEmpty; return version?.isNotEmpty ?? false;
} catch (e) { } catch (e) {
return false; return false;
} }
@@ -113,8 +206,7 @@ class FFmpegService {
static Future<String?> getVersion() async { static Future<String?> getVersion() async {
try { try {
final version = await _channel.invokeMethod('getVersion'); return await FFmpegKitConfig.getFFmpegVersion();
return version as String?;
} catch (e) { } catch (e) {
return null; return null;
} }
@@ -280,6 +372,176 @@ class FFmpegService {
return null; return null;
} }
static Future<String?> embedMetadataToOpus({
required String opusPath,
String? coverPath,
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus';
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$opusPath" ');
cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
if (coverPath != null) {
try {
final pictureBlock = await _createMetadataBlockPicture(coverPath);
if (pictureBlock != null) {
final escapedBlock = pictureBlock.replaceAll('"', '\\"');
cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ');
_log.d('Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)');
} else {
_log.w('Failed to create METADATA_BLOCK_PICTURE, skipping cover');
}
} catch (e) {
_log.e('Error creating METADATA_BLOCK_PICTURE: $e');
}
}
cmdBuffer.write('"$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg Opus embed command');
final result = await _execute(command);
if (result.success) {
try {
final tempFile = File(tempOutput);
final originalFile = File(opusPath);
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(opusPath);
await tempFile.delete();
_log.d('Opus metadata embedded successfully');
return opusPath;
} else {
_log.e('Temp Opus output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace Opus file after metadata embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (e) {
_log.w('Failed to cleanup temp Opus file: $e');
}
_log.e('Opus Metadata embed failed: ${result.output}');
return null;
}
static Future<String?> _createMetadataBlockPicture(String imagePath) async {
try {
final file = File(imagePath);
if (!await file.exists()) {
_log.e('Cover image not found: $imagePath');
return null;
}
final imageData = await file.readAsBytes();
String mimeType;
if (imagePath.toLowerCase().endsWith('.png')) {
mimeType = 'image/png';
} else if (imagePath.toLowerCase().endsWith('.jpg') ||
imagePath.toLowerCase().endsWith('.jpeg')) {
mimeType = 'image/jpeg';
} else {
if (imageData.length >= 8 &&
imageData[0] == 0x89 && imageData[1] == 0x50 &&
imageData[2] == 0x4E && imageData[3] == 0x47) {
mimeType = 'image/png';
} else if (imageData.length >= 2 &&
imageData[0] == 0xFF && imageData[1] == 0xD8) {
mimeType = 'image/jpeg';
} else {
mimeType = 'image/jpeg';
}
}
final mimeBytes = utf8.encode(mimeType);
const description = '';
final descBytes = utf8.encode(description);
final blockSize = 4 + 4 + mimeBytes.length + 4 + descBytes.length +
4 + 4 + 4 + 4 + 4 + imageData.length;
final buffer = ByteData(blockSize);
var offset = 0;
buffer.setUint32(offset, 3, Endian.big);
offset += 4;
buffer.setUint32(offset, mimeBytes.length, Endian.big);
offset += 4;
final blockBytes = Uint8List(blockSize);
blockBytes.setRange(0, offset, buffer.buffer.asUint8List());
blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes);
offset += mimeBytes.length;
final tempBuffer = ByteData(4);
tempBuffer.setUint32(0, descBytes.length, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
blockBytes.setRange(offset, offset + descBytes.length, descBytes);
offset += descBytes.length;
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
tempBuffer.setUint32(0, imageData.length, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
blockBytes.setRange(offset, offset + imageData.length, imageData);
final base64String = base64Encode(blockBytes);
return base64String;
} catch (e) {
_log.e('Error creating METADATA_BLOCK_PICTURE: $e');
return null;
}
}
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) { static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
final id3Map = <String, String>{}; final id3Map = <String, String>{};
@@ -287,7 +549,6 @@ class FFmpegService {
final key = entry.key.toUpperCase(); final key = entry.key.toUpperCase();
final value = entry.value; final value = entry.value;
// Map Vorbis comments to ID3v2 frame names
switch (key) { switch (key) {
case 'TITLE': case 'TITLE':
id3Map['title'] = value; id3Map['title'] = value;
@@ -321,7 +582,6 @@ class FFmpegService {
id3Map['lyrics'] = value; id3Map['lyrics'] = value;
break; break;
default: default:
// Pass through other tags as-is
id3Map[key.toLowerCase()] = value; id3Map[key.toLowerCase()] = value;
} }
} }
+113 -2
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -6,6 +7,10 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('HistoryDatabase'); final _log = AppLogger('HistoryDatabase');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
/// Cached current iOS container path for path normalization
String? _currentContainerPath;
/// SQLite database service for download history /// SQLite database service for download history
/// Provides O(1) lookups by spotify_id and isrc with proper indexing /// Provides O(1) lookups by spotify_id and isrc with proper indexing
@@ -78,10 +83,115 @@ class HistoryDatabase {
// Future migrations go here // Future migrations go here
} }
// ==================== iOS Path Normalization ====================
/// Pattern to match iOS container paths
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
static final _iosContainerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
caseSensitive: false,
);
/// Initialize and cache the current iOS container path
Future<void> _initContainerPath() async {
if (!Platform.isIOS || _currentContainerPath != null) return;
try {
final docDir = await getApplicationDocumentsDirectory();
// Extract container path up to and including the UUID folder
// e.g., /var/mobile/Containers/Data/Application/UUID/
final match = _iosContainerPattern.firstMatch(docDir.path);
if (match != null) {
_currentContainerPath = match.group(0);
_log.d('iOS container path: $_currentContainerPath');
}
} catch (e) {
_log.w('Failed to get iOS container path: $e');
}
}
/// Normalize iOS file path by replacing old container UUID with current one
/// This fixes the issue where iOS changes container UUID after app updates
String _normalizeIosPath(String? filePath) {
if (filePath == null || filePath.isEmpty) return filePath ?? '';
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
// Check if path contains an iOS container path
if (_iosContainerPattern.hasMatch(filePath)) {
final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!);
if (normalized != filePath) {
_log.d('Normalized iOS path: $filePath -> $normalized');
}
return normalized;
}
return filePath;
}
/// Migrate iOS paths in database to use current container UUID
/// This is called once after app update if container changed
Future<bool> migrateIosContainerPaths() async {
if (!Platform.isIOS) return false;
await _initContainerPath();
if (_currentContainerPath == null) return false;
final prefs = await _prefs;
final lastContainer = prefs.getString('ios_last_container_path');
// Skip if container hasn't changed
if (lastContainer == _currentContainerPath) {
_log.d('iOS container path unchanged, skipping migration');
return false;
}
_log.i('iOS container changed: $lastContainer -> $_currentContainerPath');
try {
final db = await database;
// Get all items with iOS paths
final rows = await db.query('history', columns: ['id', 'file_path']);
int updatedCount = 0;
final batch = db.batch();
for (final row in rows) {
final id = row['id'] as String;
final oldPath = row['file_path'] as String?;
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
final newPath = _normalizeIosPath(oldPath);
if (newPath != oldPath) {
batch.update(
'history',
{'file_path': newPath},
where: 'id = ?',
whereArgs: [id],
);
updatedCount++;
}
}
}
if (updatedCount > 0) {
await batch.commit(noResult: true);
}
// Save current container path
await prefs.setString('ios_last_container_path', _currentContainerPath!);
_log.i('iOS path migration complete: $updatedCount paths updated');
return updatedCount > 0;
} catch (e, stack) {
_log.e('iOS path migration failed: $e', e, stack);
return false;
}
}
/// Migrate data from SharedPreferences to SQLite /// Migrate data from SharedPreferences to SQLite
/// Returns true if migration was performed, false if already migrated /// Returns true if migration was performed, false if already migrated
Future<bool> migrateFromSharedPreferences() async { Future<bool> migrateFromSharedPreferences() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await _prefs;
final migrationKey = 'history_migrated_to_sqlite'; final migrationKey = 'history_migrated_to_sqlite';
if (prefs.getBool(migrationKey) == true) { if (prefs.getBool(migrationKey) == true) {
@@ -153,6 +263,7 @@ class HistoryDatabase {
} }
/// Convert DB row (snake_case) to JSON format (camelCase) /// Convert DB row (snake_case) to JSON format (camelCase)
/// Also normalizes iOS paths if container UUID changed
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) { Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
return { return {
'id': row['id'], 'id': row['id'],
@@ -161,7 +272,7 @@ class HistoryDatabase {
'albumName': row['album_name'], 'albumName': row['album_name'],
'albumArtist': row['album_artist'], 'albumArtist': row['album_artist'],
'coverUrl': row['cover_url'], 'coverUrl': row['cover_url'],
'filePath': row['file_path'], 'filePath': _normalizeIosPath(row['file_path'] as String?),
'service': row['service'], 'service': row['service'],
'downloadedAt': row['downloaded_at'], 'downloadedAt': row['downloaded_at'],
'isrc': row['isrc'], 'isrc': row['isrc'],
+3 -2
View File
@@ -19,8 +19,9 @@ class PaletteService {
return null; return null;
} }
if (_colorCache.containsKey(imageUrl)) { final cached = _colorCache[imageUrl];
return _colorCache[imageUrl]; if (cached != null) {
return cached;
} }
try { try {
+6 -7
View File
@@ -323,7 +323,6 @@ class PlatformBridge {
}); });
} }
/// Returns true if credentials are available (custom or env vars)
static Future<bool> hasSpotifyCredentials() async { static Future<bool> hasSpotifyCredentials() async {
final result = await _channel.invokeMethod('hasSpotifyCredentials'); final result = await _channel.invokeMethod('hasSpotifyCredentials');
return result as bool; return result as bool;
@@ -343,11 +342,12 @@ class PlatformBridge {
await _channel.invokeMethod('clearTrackCache'); await _channel.invokeMethod('clearTrackCache');
} }
static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 2, String? filter}) async {
final result = await _channel.invokeMethod('searchDeezerAll', { final result = await _channel.invokeMethod('searchDeezerAll', {
'query': query, 'query': query,
'track_limit': trackLimit, 'track_limit': trackLimit,
'artist_limit': artistLimit, 'artist_limit': artistLimit,
'filter': filter ?? '',
}); });
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
@@ -409,7 +409,6 @@ class PlatformBridge {
return logs.map((e) => e as Map<String, dynamic>).toList(); return logs.map((e) => e as Map<String, dynamic>).toList();
} }
/// Get logs since a specific index (for incremental updates)
static Future<Map<String, dynamic>> getGoLogsSince(int index) async { static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
final result = await _channel.invokeMethod('getLogsSince', {'index': index}); final result = await _channel.invokeMethod('getLogsSince', {'index': index});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
@@ -560,7 +559,7 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
static Future<Map<String, dynamic>> downloadWithExtensions({ static Future<Map<String, dynamic>> downloadWithExtensions({
required String isrc, required String isrc,
required String spotifyId, required String spotifyId,
required String trackName, required String trackName,
@@ -583,8 +582,9 @@ class PlatformBridge {
String? genre, String? genre,
String? label, String? label,
String lyricsMode = 'embed', String lyricsMode = 'embed',
String? preferredService,
}) async { }) async {
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}${preferredService != null ? ' (service: $preferredService)' : ''}');
final request = jsonEncode({ final request = jsonEncode({
'isrc': isrc, 'isrc': isrc,
'spotify_id': spotifyId, 'spotify_id': spotifyId,
@@ -608,6 +608,7 @@ class PlatformBridge {
'genre': genre ?? '', 'genre': genre ?? '',
'label': label ?? '', 'label': label ?? '',
'lyrics_mode': lyricsMode, 'lyrics_mode': lyricsMode,
'service': preferredService ?? '',
}); });
final result = await _channel.invokeMethod('downloadWithExtensions', request); final result = await _channel.invokeMethod('downloadWithExtensions', request);
@@ -794,7 +795,6 @@ class PlatformBridge {
} }
} }
/// Get extension home feed
static Future<Map<String, dynamic>?> getExtensionHomeFeed(String extensionId) async { static Future<Map<String, dynamic>?> getExtensionHomeFeed(String extensionId) async {
try { try {
final result = await _channel.invokeMethod('getExtensionHomeFeed', { final result = await _channel.invokeMethod('getExtensionHomeFeed', {
@@ -808,7 +808,6 @@ class PlatformBridge {
} }
} }
/// Get extension browse categories
static Future<Map<String, dynamic>?> getExtensionBrowseCategories(String extensionId) async { static Future<Map<String, dynamic>?> getExtensionBrowseCategories(String extensionId) async {
try { try {
final result = await _channel.invokeMethod('getExtensionBrowseCategories', { final result = await _channel.invokeMethod('getExtensionBrowseCategories', {
+8 -4
View File
@@ -9,6 +9,12 @@ class ShareIntentService {
factory ShareIntentService() => _instance; factory ShareIntentService() => _instance;
ShareIntentService._internal(); ShareIntentService._internal();
static final RegExp _spotifyUriPattern =
RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+');
static final RegExp _spotifyUrlPattern = RegExp(
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
);
final _sharedUrlController = StreamController<String>.broadcast(); final _sharedUrlController = StreamController<String>.broadcast();
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription; StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
bool _initialized = false; bool _initialized = false;
@@ -57,14 +63,12 @@ class ShareIntentService {
String? _extractSpotifyUrl(String text) { String? _extractSpotifyUrl(String text) {
if (text.isEmpty) return null; if (text.isEmpty) return null;
final uriMatch = RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+').firstMatch(text); final uriMatch = _spotifyUriPattern.firstMatch(text);
if (uriMatch != null) { if (uriMatch != null) {
return uriMatch.group(0); return uriMatch.group(0);
} }
final urlMatch = RegExp( final urlMatch = _spotifyUrlPattern.firstMatch(text);
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
).firstMatch(text);
if (urlMatch != null) { if (urlMatch != null) {
final fullUrl = urlMatch.group(0)!; final fullUrl = urlMatch.group(0)!;
final queryIndex = fullUrl.indexOf('?'); final queryIndex = fullUrl.indexOf('?');
+6 -12
View File
@@ -10,7 +10,7 @@ class LogEntry {
final String tag; final String tag;
final String message; final String message;
final String? error; final String? error;
final bool isFromGo; // Track if this log came from Go backend final bool isFromGo;
LogEntry({ LogEntry({
required this.timestamp, required this.timestamp,
@@ -47,8 +47,6 @@ class LogBuffer extends ChangeNotifier {
Timer? _goLogTimer; Timer? _goLogTimer;
int _lastGoLogIndex = 0; int _lastGoLogIndex = 0;
/// Whether logging is enabled (controlled by settings)
/// User must enable "Detailed Logging" in settings to capture logs
static bool _loggingEnabled = false; static bool _loggingEnabled = false;
static bool get loggingEnabled => _loggingEnabled; static bool get loggingEnabled => _loggingEnabled;
static set loggingEnabled(bool value) { static set loggingEnabled(bool value) {
@@ -64,7 +62,6 @@ class LogBuffer extends ChangeNotifier {
int get length => _entries.length; int get length => _entries.length;
void add(LogEntry entry) { void add(LogEntry entry) {
// Skip adding if logging is disabled (except for errors which are always logged)
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') { if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
return; return;
} }
@@ -76,7 +73,6 @@ class LogBuffer extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Start polling Go backend logs
void startGoLogPolling() { void startGoLogPolling() {
_goLogTimer?.cancel(); _goLogTimer?.cancel();
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { _goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
@@ -84,13 +80,11 @@ class LogBuffer extends ChangeNotifier {
}); });
} }
/// Stop polling Go backend logs
void stopGoLogPolling() { void stopGoLogPolling() {
_goLogTimer?.cancel(); _goLogTimer?.cancel();
_goLogTimer = null; _goLogTimer = null;
} }
/// Fetch logs from Go backend since last index
Future<void> _fetchGoLogs() async { Future<void> _fetchGoLogs() async {
try { try {
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex); final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
@@ -103,7 +97,6 @@ class LogBuffer extends ChangeNotifier {
final tag = log['tag'] as String? ?? 'Go'; final tag = log['tag'] as String? ?? 'Go';
final message = log['message'] as String? ?? ''; final message = log['message'] as String? ?? '';
// Parse timestamp (format: "15:04:05.000")
DateTime parsedTime = DateTime.now(); DateTime parsedTime = DateTime.now();
if (timestamp.isNotEmpty) { if (timestamp.isNotEmpty) {
try { try {
@@ -159,15 +152,17 @@ class LogBuffer extends ChangeNotifier {
} }
List<LogEntry> filter({String? level, String? tag, String? search}) { List<LogEntry> filter({String? level, String? tag, String? search}) {
final tagLower = tag?.toLowerCase();
final searchLower = search?.toLowerCase();
return _entries.where((entry) { return _entries.where((entry) {
if (level != null && level != 'ALL' && entry.level != level) { if (level != null && level != 'ALL' && entry.level != level) {
return false; return false;
} }
if (tag != null && !entry.tag.toLowerCase().contains(tag.toLowerCase())) { if (tagLower != null && !entry.tag.toLowerCase().contains(tagLower)) {
return false; return false;
} }
if (search != null && search.isNotEmpty) { if (searchLower != null && searchLower.isNotEmpty) {
final searchLower = search.toLowerCase();
return entry.message.toLowerCase().contains(searchLower) || return entry.message.toLowerCase().contains(searchLower) ||
entry.tag.toLowerCase().contains(searchLower) || entry.tag.toLowerCase().contains(searchLower) ||
(entry.error?.toLowerCase().contains(searchLower) ?? false); (entry.error?.toLowerCase().contains(searchLower) ?? false);
@@ -219,7 +214,6 @@ class BufferedOutput extends LogOutput {
} }
} }
/// Global logger instance for the app
final log = Logger( final log = Logger(
printer: PrettyPrinter( printer: PrettyPrinter(
methodCount: 0, methodCount: 0,

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