Compare commits

...

189 Commits

Author SHA1 Message Date
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
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 543cb45c11 Merge pull request #104 from Amonoman/main
Update about_page.dart
2026-01-25 03:20:53 +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
zarzet 3f42128cb9 fix: update Telegram community link and VirusTotal hash for v3.2.1 2026-01-22 04:50:46 +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
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
zarzet 46afa6e733 fix: use HTML parse mode for Telegram notifications to handle special chars 2026-01-21 13:30:35 +07:00
zarzet c01b189477 fix: discography download context issue after quality picker closes 2026-01-21 13:04:48 +07:00
zarzet 966935b677 feat: add missing platform bridge functions for batch duplicate check and cross-platform IDs 2026-01-21 12:22:46 +07:00
zarzet f2f8ca4528 feat: artist navigation from album, UI improvements, concise changelog
- Add tappable artist name in album screen to navigate to artist page
- Show track number instead of cover image in album track list
- Add release date badge next to track count on album screen
- Modernize Download All buttons with rounded corners (borderRadius: 24)
- Add downloaded indicator for recent items (primary colored subtitle)
- Condense v3.2.0 changelog and add note about concise format
- Fix withOpacity deprecation and unnecessary null assertion in home_tab
- Go backend: add artist_id support for Spotify, Deezer, and extensions
2026-01-21 12:07:50 +07:00
zarzet 7844bd2f42 docs: add discography download to changelog as highly requested feature 2026-01-21 10:28:17 +07:00
zarzet ac3d51e2cd feat: add discography download with album selection support
- Download entire artist discography, albums only, or singles only
- Album selection mode with multi-select and batch download
- Progress dialog while fetching tracks from albums
- Skip already downloaded tracks (checks history)
- Works with Spotify, Deezer, and Extensions
- Add 18 localization strings for discography feature
2026-01-21 10:26:35 +07:00
zarzet b899b54bb8 perf: migrate history to SQLite and optimize palette extraction
- Add SQLite database for download history with O(1) indexed lookups
- Add in-memory Map indexes for O(1) getBySpotifyId/getByIsrc
- Automatic migration from SharedPreferences on first launch
- Fix PaletteService to use PaletteGenerator (isolate approach didn't work)
- Use small image size (64x64) and limited colors (8) for speed
- Add caching to avoid re-extraction
- All screens now use consistent PaletteService
- Update CHANGELOG with all v3.2.0 changes
2026-01-21 10:05:39 +07:00
zarzet 7a17de49b2 fix: add duration_ms to home feed items and bump version to 3.2.0
- Add duration_ms field to ExploreItem model
- Parse duration_ms from spotify-web and ytmusic home feed responses
- Update _downloadExploreTrack to use item.durationMs
- Fixes track duration showing 0:00 in metadata screen after download
- Bump version to 3.2.0+63
2026-01-21 09:16:11 +07:00
zarzet 79180dd918 feat: add Home Feed with pull-to-refresh and gobackend.getLocalTime() API
- Add Home Feed/Explore feature with extension capabilities system
- Add pull-to-refresh on home feed (replaces refresh button)
- Add gobackend.getLocalTime() API for accurate device timezone detection
- Add YT Music Quick Picks UI with swipeable vertical format
- Fix greeting time showing wrong time due to Goja getTimezoneOffset() returning 0
- Update spotify-web and ytmusic extensions to use getLocalTime()
- Add Turkish language support
- Update CHANGELOG for v3.2.0
2026-01-21 08:30:44 +07:00
zarzet ac5f74a48f feat: convert GitHub Markdown to Telegram format in release notification 2026-01-20 10:12:18 +07:00
zarzet e725a7be77 feat: convert GitHub Markdown to Telegram format in release notification 2026-01-20 10:12:01 +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 d960708dac feat: improve Telegram notification - upload IPA, remove redundant links, increase changelog limit 2026-01-20 10:08:35 +07:00
zarzet c62ad005f5 docs: update README and release workflow 2026-01-20 09:58:31 +07:00
zarzet 3edfe8e8bb docs: update README and release workflow 2026-01-20 09:56:38 +07:00
zarzet 68fa1bfdae feat: improve providers, l10n updates, and UI enhancements (testing) 2026-01-20 09:55:46 +07:00
zarzet 6f9722e05b Merge dev: update screenshots, funding, and VirusTotal hash 2026-01-20 05:58:06 +07:00
zarzet bd6b23400e Update screenshots, funding links, and VirusTotal hash 2026-01-20 05:57:43 +07:00
zarzet 066d35967e Merge branch 'dev' 2026-01-20 04:55:27 +07:00
zarzet b6d2fea847 chore: bump version to 3.1.3+62 2026-01-20 04:55:02 +07:00
zarzet 2b932cff70 Merge branch 'dev' 2026-01-20 04:16:26 +07:00
zarzet f356e53f7e feat: auto-enrich genre/label from Deezer for built-in providers
- Add GetExtendedMetadataByISRC function in deezer.go
  - Searches track by ISRC then fetches album extended metadata
- Call enrichment in DownloadWithExtensionFallback before built-in download
  - Only enriches if genre/label are empty and ISRC is available
  - Logs enrichment results for debugging
2026-01-20 04:09:41 +07:00
zarzet bb1ff187a3 fix: include genre, label, copyright in DownloadResponse
Extended metadata was being embedded into FLAC files but not returned
in the response to Flutter, causing history to not store these fields.

Fixed in 3 places in extension_providers.go:
- Source extension download response
- Extension fallback download response
- Built-in provider (Tidal/Qobuz/Amazon) response
2026-01-20 03:59:55 +07:00
zarzet d99a1b1c21 perf: streaming M4A metadata embedding and HTTP client refactor
- Refactor EmbedM4AMetadata to use streaming instead of loading entire file
- Use os.Open + ReadAt instead of os.ReadFile for memory efficiency
- Atomic file replacement via temp file + rename for safer writes
- New helper functions: findAtomInRange, readAtomHeaderAt, copyRange, buildUdtaAtom
- Refactor GetM4AQuality to use streaming with findAudioSampleEntry
- Use NewHTTPClientWithTimeout helper in lyrics.go, qobuz.go, tidal.go
- Update CHANGELOG with performance improvements and MP3 metadata support
2026-01-20 03:46:43 +07:00
zarzet c36497e87c perf: optimize widget rebuilds and reduce allocations
- Cache SharedPreferences instance in DownloadHistoryNotifier and DownloadQueueNotifier
- Precompile regex for folder sanitization and year extraction
- Use indexWhere instead of firstWhere with placeholder object
- Use selective watch for downloadQueueProvider (queuedCount, items)
- Pass Track directly to _buildTrackTile instead of index lookup
- Pass historyItems as parameter to _buildRecentAccess
- Add extended metadata (genre, label, copyright) support for MP3
2026-01-20 03:25:33 +07:00
zarzet 03027813c1 chore: cleanup unused code and dead imports 2026-01-20 02:10:10 +07:00
zarzet 8e9d0c3e9a fix: use path only for JsonCacheInfoRepository
JsonCacheInfoRepository assertion requires either path OR databaseName, not both.
Using path only to ensure database is stored in persistent directory.
2026-01-19 23:26:10 +07:00
zarzet 6c8813c9de fix: ensure CoverCacheManager initializes before app renders
- Move CoverCacheManager.initialize() to run BEFORE other services
- Add debug log to confirm initialization status
- Fixes race condition where widgets render before cache is ready
2026-01-19 23:22:53 +07:00
zarzet ec314eb479 fix: store cache database in persistent directory
- Add path parameter to JsonCacheInfoRepository
- Add fallback to DefaultCacheManager if initialization fails
- Add debug logging for troubleshooting
- Fix issue where cache database was in temp dir while files in persistent
2026-01-19 23:14:33 +07:00
zarzet 77e4457244 feat: add persistent cover image cache
- Add CoverCacheManager service for persistent image caching
- Cache stored in app_flutter/cover_cache/ (not cleared by system)
- Maximum 1000 images cached for up to 365 days
- Update all 11 screens to use persistent cache manager
- Add flutter_cache_manager and path dependencies
- Update CHANGELOG.md with all changes for v3.1.3
2026-01-19 22:55:53 +07:00
zarzet 0119db094d feat: add extended metadata (genre, label, copyright) support
- Add genre, label, copyright fields to ExtTrackMetadata and DownloadResponse
- Add utils.randomUserAgent() for extensions to get random User-Agent strings
- Fix VM race condition panic by adding mutex locks to all provider methods
- Fix Tidal release date fallback when req.ReleaseDate is empty
- Display genre, label, copyright in track metadata screen
- Store extended metadata in download history for persistence
- Add trackGenre, trackLabel, trackCopyright localization strings
2026-01-19 21:13:40 +07:00
zarzet 9c35515d6f docs: add code of conduct and contributing guidelines 2026-01-19 18:58:25 +07:00
zarzet 1546d7da22 feat: add external LRC lyrics file support and fix locale parsing
- Add lyrics mode setting (embed/external/both) for saving lyrics
- Implement SaveLRCFile() in Go backend for all providers (Tidal, Qobuz, Amazon)
- Fix locale parsing in app.dart to handle country codes (e.g., pt_PT -> Locale('pt', 'PT'))
- Change Portuguese label from Portugal to Brasil in language settings
2026-01-19 18:57:27 +07:00
zarzet 61720f3f2a chore(ios): sync FFmpeg service and add palette_generator dependency 2026-01-19 02:55:39 +07:00
zarzet 7749399239 docs: add translator credits to changelog 2026-01-19 02:41:57 +07:00
zarzet d143b82068 fix: add es_ES and pt_PT locale codes to language selector 2026-01-19 02:33:12 +07:00
zarzet 606e7c1079 fix: change translator links from GitHub to Crowdin profiles 2026-01-19 02:28:35 +07:00
zarzet a650632c4e feat: add translators section in about page and fix ARB locale format 2026-01-19 02:25:30 +07:00
zarzet 3c118f74e4 chore: rename ARB files and add Spanish/Portuguese languages 2026-01-19 02:17:32 +07:00
zarzet bc3055f6e1 chore: update supported locales 2026-01-19 02:14:54 +07:00
zarzet 7c86ae0b7e feat: add quick search provider switcher and genre/label for extensions
- Add dropdown menu in search bar for instant provider switching
- Support genre & label metadata for extension downloads
- Bump version to 3.1.2 (build 61)
2026-01-19 02:14:52 +07:00
zarzet 595bfb2711 feat: add button setting type for extension actions
- Add SettingTypeButton for action buttons in extension settings
- Add Action field to ExtensionSetting for JS function name
- Update extension detail page UI to render button settings
- Add InvokeAction method to execute button actions
2026-01-19 02:14:52 +07:00
zarzet 5f39a3d52f fix: use CollapseMode.none for smoother header animation 2026-01-19 02:14:50 +07:00
zarzet e7077781e6 feat: add genre and label metadata to FLAC downloads
- Fetch genre and label from Deezer album API before download
- Add GENRE, ORGANIZATION (label), and COPYRIGHT tags to FLAC files
- Update Go Metadata struct with new fields
- Add GetDeezerExtendedMetadata export function for Flutter
- Register platform channel handlers for Android and iOS
- Pass genre/label through download flow to all services (Tidal/Qobuz/Amazon)
2026-01-19 02:14:50 +07:00
zarzet 42d15db4ca fix: show 'Artist' label for artist items instead of 'Album'
Fixed fallback subtitle in _CollectionItemWidget for artist search results
2026-01-19 02:14:49 +07:00
zarzet c2599981d6 fix: Clear All now hides ALL downloads, not just visible 10
Previously only hid uniqueItems (max 10 visible), now hides all downloadItems
2026-01-19 02:14:48 +07:00
zarzet a1647a41ff fix: use ref.watch for hiddenDownloadIds reactivity
Show All Downloads button now updates immediately without restart
2026-01-19 02:14:47 +07:00
zarzet bf2fc7702b chore: remove debug print statements from recent_access_provider 2026-01-19 02:14:46 +07:00
zarzet f814408702 style: reduce AppBar title font size to 16px for long titles 2026-01-19 02:14:45 +07:00
zarzet 6b1958bfd0 feat: show 'Show All Downloads' button when recents is empty
- Button appears when all items are cleared/hidden
- Clicking resets hidden downloads list
- Clear All button only shows when there are items
- Empty state with visibility_off icon
2026-01-19 02:14:29 +07:00
zarzet bc120ffa76 feat: allow hiding downloads from recents without deleting files
- Add hiddenDownloadIds set to RecentAccessState
- X button on download items hides from recents (not delete file)
- Hidden IDs persisted in SharedPreferences
- Clear All also clears hidden downloads list
- Single track shows as Track, 2+ tracks shows as Album in recents
2026-01-19 02:14:27 +07:00
zarzet 5ea454a0b0 fix: downloaded album navigation from recents 2026-01-19 02:14:26 +07:00
zarzet da574f895c feat: v3.1.2 - MP3 option, dominant color headers, sticky titles, disc separation
Added:
- MP3 quality option with FLAC-to-MP3 conversion (320kbps)
- Dominant color header backgrounds on detail screens
- Spotify-style sticky title on scroll (album, playlist, artist screens)
- Disc separation for multi-disc albums
- Album grouping in recent downloads
- 50% screen width cover art

Changed:
- Improved FFmpeg FLAC-to-MP3 conversion workflow
- AppBar uses theme surface color when collapsed

Fixed:
- Empty catch blocks with proper comments
- Russian plural forms (ICU syntax)

Dependencies:
- Added palette_generator ^0.3.3+4
2026-01-19 02:13:53 +07:00
Zarz Eleutherius 1c445e91d9 Merge pull request #77 from zarzet/l10n_dev
New Crowdin updates
2026-01-19 02:12:44 +07:00
Zarz Eleutherius 5d03eb0656 New translations app_en.arb (Portuguese) 2026-01-19 02:11:51 +07:00
Zarz Eleutherius becb6845a6 Merge pull request #68 from zarzet/l10n_dev
New Crowdin updates
2026-01-19 00:48:32 +07:00
Zarz Eleutherius be3ee3b216 New translations app_en.arb (Chinese Traditional) 2026-01-19 00:29:39 +07:00
Zarz Eleutherius 3747674968 New translations app_en.arb (Russian) 2026-01-19 00:29:37 +07:00
Zarz Eleutherius ff9d088c5f New translations app_en.arb (German) 2026-01-19 00:29:34 +07:00
Zarz Eleutherius 12db11d559 New translations app_en.arb (Spanish) 2026-01-19 00:29:33 +07:00
Zarz Eleutherius 7e1aca33a5 New translations app_en.arb (Hindi) 2026-01-18 03:42:29 +07:00
Zarz Eleutherius 07a1c68354 New translations app_en.arb (Indonesian) 2026-01-18 03:42:28 +07:00
Zarz Eleutherius f4d7c6531f New translations app_en.arb (Chinese Traditional) 2026-01-18 03:42:27 +07:00
Zarz Eleutherius e9ca054682 New translations app_en.arb (Chinese Simplified) 2026-01-18 03:42:27 +07:00
Zarz Eleutherius 1069bdd0d8 New translations app_en.arb (Portuguese) 2026-01-18 03:42:25 +07:00
Zarz Eleutherius ff882a58d7 New translations app_en.arb (Dutch) 2026-01-18 03:42:25 +07:00
Zarz Eleutherius dddc8c3d94 New translations app_en.arb (Korean) 2026-01-18 03:42:24 +07:00
Zarz Eleutherius 720525b67b New translations app_en.arb (German) 2026-01-18 03:42:22 +07:00
Zarz Eleutherius cc12f63d36 New translations app_en.arb (Spanish) 2026-01-18 03:42:21 +07:00
Zarz Eleutherius 5c67553596 New translations app_en.arb (French) 2026-01-18 03:42:20 +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
135 changed files with 35405 additions and 6546 deletions
+4
View File
@@ -0,0 +1,4 @@
github: zarzet
ko_fi: zarzet
buy_me_a_coffee: zarzet
+135 -20
View File
@@ -71,7 +71,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.21"
go-version: "1.25"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
@@ -174,7 +174,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.21"
go-version: "1.25"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
@@ -194,7 +194,7 @@ jobs:
working-directory: go_backend
run: |
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:
CGO_ENABLED: 1
@@ -249,23 +249,6 @@ jobs:
channel: "stable"
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
run: flutter pub get
@@ -412,3 +395,135 @@ jobs:
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
notify-telegram:
runs-on: ubuntu-latest
needs: [get-version, create-release]
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download Android APK
uses: actions/download-artifact@v4
with:
name: android-apk
path: ./release
- name: Download iOS IPA
uses: actions/download-artifact@v4
with:
name: ios-ipa
path: ./release
- name: Extract changelog for version
id: changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v}
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
# 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
CHANGELOG="See release notes on GitHub for details."
else
# Convert GitHub Markdown to Telegram HTML:
# - **text** → <b>text</b>
# - `code` → <code>code</code>
# - ### Header → <b>Header</b>
# - Escape HTML special chars first
# - Remove > blockquote prefix
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
sed 's/^> //' | \
sed 's/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^- /• /g' | \
sed 's/^ - / ◦ /g')
# Take first 2500 characters, then cut at last complete line
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
# Check if truncated
FULL_LEN=${#FULL_CHANGELOG}
if [ $FULL_LEN -gt 2500 ]; then
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
fi
fi
echo "$CHANGELOG" > /tmp/changelog.txt
echo "DEBUG: Final changelog:"
cat /tmp/changelog.txt
- name: Send to Telegram Channel
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }}
run: |
VERSION=${{ needs.get-version.outputs.version }}
CHANGELOG=$(cat /tmp/changelog.txt)
# Find APK files
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
# Prepare message with changelog (HTML format)
printf '%s\n' \
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
"" \
"<b>What's New:</b>" \
"${CHANGELOG}" \
"" \
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
> /tmp/telegram_message.txt
MESSAGE=$(cat /tmp/telegram_message.txt)
# 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" \
--data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \
--data-urlencode "text=${MESSAGE}" \
--data-urlencode "parse_mode=HTML" \
--data-urlencode "disable_web_page_preview=true" || true
# Upload arm64 APK to channel
if [ -f "$ARM64_APK" ]; then
echo "Uploading arm64 APK to Telegram..."
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM64_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
fi
# Upload arm32 APK to channel
if [ -f "$ARM32_APK" ]; then
echo "Uploading arm32 APK to Telegram..."
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM32_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm32"
fi
# Upload iOS IPA to channel
IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1)
if [ -f "$IOS_IPA" ]; then
echo "Uploading iOS IPA to Telegram..."
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${IOS_IPA}" \
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
fi
echo "Telegram notification sent!"
+1
View File
@@ -72,3 +72,4 @@ flutter_*.log
# Development tools
tool/
.claude/settings.local.json
+378 -1648
View File
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**[zarzet](https://github.com/zarzet)**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+268
View File
@@ -0,0 +1,268 @@
# Contributing to SpotiFLAC
First off, thank you for considering contributing to SpotiFLAC! 🎉
This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [How Can I Contribute?](#how-can-i-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Features](#suggesting-features)
- [Code Contributions](#code-contributions)
- [Translations](#translations)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Coding Guidelines](#coding-guidelines)
- [Commit Guidelines](#commit-guidelines)
- [Pull Request Process](#pull-request-process)
## Code of Conduct
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
## How Can I Contribute?
### Reporting Bugs
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
When creating a bug report, please use the bug report template and include:
- **Clear and descriptive title**
- **Steps to reproduce** the issue
- **Expected behavior** vs **actual behavior**
- **Screenshots or screen recordings** if applicable
- **Device information** (model, OS version)
- **App version**
- **Logs** from Settings > About > View Logs
### Suggesting Features
Feature requests are welcome! Please use the feature request template and:
- **Check existing issues** to avoid duplicates
- **Describe the feature** clearly
- **Explain the use case** - why would this be useful?
- **Consider the scope** - is this a small enhancement or a major feature?
### Code Contributions
1. **Fork the repository** and create your branch from `dev`
2. **Make your changes** following our coding guidelines
3. **Test your changes** thoroughly
4. **Submit a pull request** to the `dev` branch
### Translations
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
2. Select your language or request a new one
3. Start translating!
Translation files are located in `lib/l10n/arb/`.
## Development Setup
### Prerequisites
- **Flutter SDK** 3.10.0 or higher
- **Dart SDK** 3.10.0 or higher
- **Android Studio** or **VS Code** with Flutter extensions
- **Git**
### Getting Started
1. **Clone your fork**
```bash
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
cd SpotiFLAC-Mobile
```
2. **Add upstream remote**
```bash
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
3. **Install dependencies**
```bash
flutter pub get
```
4. **Generate code** (for Riverpod, JSON serialization, etc.)
```bash
dart run build_runner build --delete-conflicting-outputs
```
5. **Run the app**
```bash
flutter run
```
### Building
```bash
# Debug build
flutter build apk --debug
# Release build
flutter build apk --release
```
## Project Structure
```
lib/
├── l10n/ # Localization files
│ └── arb/ # ARB translation files
├── models/ # Data models
├── providers/ # Riverpod providers
├── screens/ # UI screens
│ └── settings/ # Settings sub-screens
├── services/ # Business logic services
├── theme/ # App theming
├── utils/ # Utility functions
├── widgets/ # Reusable widgets
├── app.dart # App configuration
└── main.dart # Entry point
```
## Coding Guidelines
### General
- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines
- Use meaningful variable and function names
- Keep functions small and focused
- Add comments for complex logic
### Formatting
- Use `dart format` before committing
- Maximum line length: 80 characters
- Use trailing commas for better formatting
```bash
dart format .
```
### Linting
Ensure your code passes all lints:
```bash
flutter analyze
```
### State Management
We use **Riverpod** for state management. Follow these patterns:
```dart
// Use code generation with riverpod_annotation
@riverpod
class MyNotifier extends _$MyNotifier {
@override
MyState build() => MyState();
// Methods to update state
}
```
### Localization
All user-facing strings should be localized:
```dart
// Good
Text(AppLocalizations.of(context)!.downloadComplete)
// Bad
Text('Download Complete')
```
To add new strings:
1. Add the key to `lib/l10n/arb/app_en.arb`
2. Run `flutter gen-l10n`
## Commit Guidelines
We follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
```
### Types
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `style`: Code style changes (formatting, etc.)
- `refactor`: Code refactoring
- `perf`: Performance improvements
- `test`: Adding or updating tests
- `chore`: Maintenance tasks
### Examples
```
feat(download): add batch download support
fix(ui): resolve overflow on small screens
docs: update contributing guidelines
chore(deps): update flutter_riverpod to 3.1.0
```
## Pull Request Process
1. **Update your fork**
```bash
git fetch upstream
git rebase upstream/dev
```
2. **Create a feature branch**
```bash
git checkout -b feat/my-new-feature
```
3. **Make your changes** and commit following our guidelines
4. **Push to your fork**
```bash
git push origin feat/my-new-feature
```
5. **Create a Pull Request**
- Target the `dev` branch
- Fill in the PR template
- Link related issues
6. **Address review feedback**
- Make requested changes
- Push additional commits
- Request re-review when ready
### PR Requirements
- [ ] Code follows project conventions
- [ ] All tests pass
- [ ] No new linting errors
- [ ] Documentation updated (if needed)
- [ ] Commit messages follow guidelines
- [ ] PR description is clear and complete
## Questions?
If you have questions, feel free to:
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
Thank you for contributing! 💚
+29 -3
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)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
[![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/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3)
[![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">
@@ -52,6 +52,18 @@ Want to create your own extension? Check out the [Extension Development Guide](h
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
## Telegram
<p align="center">
<a href="https://t.me/spotiflac">
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflac_chat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
## FAQ
**Q: Why is my download failing with "Song not found"?**
@@ -69,7 +81,16 @@ A: The app needs permission to save downloaded files to your device. On Android
**Q: Is this app safe?**
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20Me-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/zarzet)
**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.
### 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
@@ -85,3 +106,8 @@ You are solely responsible for:
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.
> [!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.
+71 -2
View File
@@ -5,6 +5,7 @@
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
-keep class io.flutter.embedding.** { *; }
# Ignore missing Play Core classes (not used, but referenced by Flutter)
-dontwarn com.google.android.play.core.splitcompat.**
@@ -14,9 +15,15 @@
# Ignore missing javax.xml.stream (not used on Android)
-dontwarn javax.xml.stream.**
# Go backend (gobackend.aar)
# Go backend (gobackend.aar) - CRITICAL for release builds
-keep class gobackend.** { *; }
-keep class go.** { *; }
-keep interface gobackend.** { *; }
-keepclassmembers class gobackend.** { *; }
# Go mobile binding internals
-keep class org.golang.** { *; }
-dontwarn org.golang.**
# FFmpeg Kit
-keep class com.arthenica.ffmpegkit.** { *; }
@@ -30,15 +37,77 @@
native <methods>;
}
# Kotlin coroutines
# Kotlin coroutines - expanded rules
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** {
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
-keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable
-keepattributes Signature
-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,155 @@
package com.zarz.spotiflac
import android.content.Intent
import android.os.Build
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant
import gobackend.Gobackend
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
class MainActivity: FlutterActivity() {
private val CHANNEL = "com.zarz.spotiflac/backend"
private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg"
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) {
super.onNewIntent(intent)
// Update the intent so receive_sharing_intent can access the new data
@@ -26,6 +158,7 @@ class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
GeneratedPluginRegistrant.registerWith(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
scope.launch {
@@ -139,6 +272,28 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"checkDuplicatesBatch" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
val tracksJson = call.argument<String>("tracks") ?: "[]"
val response = withContext(Dispatchers.IO) {
Gobackend.checkDuplicatesBatch(outputDir, tracksJson)
}
result.success(response)
}
"preBuildDuplicateIndex" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
withContext(Dispatchers.IO) {
Gobackend.preBuildDuplicateIndex(outputDir)
}
result.success(null)
}
"invalidateDuplicateIndex" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
withContext(Dispatchers.IO) {
Gobackend.invalidateDuplicateIndex(outputDir)
}
result.success(null)
}
"buildFilename" -> {
val template = call.argument<String>("template") ?: ""
val metadata = call.argument<String>("metadata") ?: "{}"
@@ -256,9 +411,10 @@ class MainActivity: FlutterActivity() {
"searchDeezerAll" -> {
val query = call.argument<String>("query") ?: ""
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) {
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong())
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
@@ -284,6 +440,13 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"getDeezerExtendedMetadata" -> {
val trackId = call.argument<String>("track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getDeezerExtendedMetadata(trackId)
}
result.success(response)
}
"convertSpotifyToDeezer" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: ""
@@ -299,6 +462,43 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"checkAvailabilityFromDeezerID" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkAvailabilityFromDeezerID(deezerTrackId)
}
result.success(response)
}
"checkAvailabilityByPlatformID" -> {
val platform = call.argument<String>("platform") ?: ""
val entityType = call.argument<String>("entity_type") ?: ""
val entityId = call.argument<String>("entity_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkAvailabilityByPlatformID(platform, entityType, entityId)
}
result.success(response)
}
"getSpotifyIDFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyIDFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
"getTidalURLFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getTidalURLFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
"getAmazonURLFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
// Log methods
"getLogs" -> {
val response = withContext(Dispatchers.IO) {
@@ -438,6 +638,14 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"invokeExtensionAction" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val actionName = call.argument<String>("action") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.invokeExtensionActionJSON(extensionId, actionName)
}
result.success(response)
}
"searchTracksWithExtensions" -> {
val query = call.argument<String>("query") ?: ""
val limit = call.argument<Int>("limit") ?: 20
@@ -453,6 +661,14 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
val response = withContext(Dispatchers.IO) {
Gobackend.enrichTrackWithExtensionJSON(extensionId, trackJson)
}
result.success(response)
}
"removeExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
withContext(Dispatchers.IO) {
@@ -663,6 +879,21 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
// Extension Home Feed (Explore)
"getExtensionHomeFeed" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionHomeFeedJSON(extensionId)
}
result.success(response)
}
"getExtensionBrowseCategories" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionBrowseCategoriesJSON(extensionId)
}
result.success(response)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
@@ -670,37 +901,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)
}
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 81 KiB

-208
View File
@@ -1,208 +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
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
final dir = File(inputPath).parent.path;
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}MP3';
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.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) 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;
}
/// 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 -314
View File
@@ -3,7 +3,6 @@ package gobackend
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -12,85 +11,31 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
type AmazonDownloader struct {
client *http.Client
regions []string // us, eu regions for DoubleDouble service
lastAPICallTime time.Time // Rate limiting: track last API call
apiCallCount int // Rate limiting: counter per minute
apiCallResetTime time.Time // Rate limiting: reset time
client *http.Client
}
var (
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
amazonRateLimitMu sync.Mutex
)
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
type DoubleDoubleSubmitResponse struct {
Success bool `json:"success"`
ID string `json:"id"`
// AfkarXYZResponse is the response from AfkarXYZ API
type AfkarXYZResponse struct {
Success bool `json:"success"`
Data struct {
DirectLink string `json:"direct_link"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
} `json:"data"`
}
// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint
type DoubleDoubleStatusResponse struct {
Status string `json:"status"`
FriendlyStatus string `json:"friendlyStatus"`
URL string `json:"url"`
Current struct {
Name string `json:"name"`
Artist string `json:"artist"`
} `json:"current"`
}
// amazonArtistsMatch checks if the artist names are similar enough
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
}
// amazonIsASCIIString checks if a string contains only ASCII characters
func amazonIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
@@ -100,241 +45,68 @@ func amazonIsASCIIString(s string) bool {
return true
}
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
regions: []string{"us", "eu"}, // Same regions as PC
apiCallResetTime: time.Now(),
client: NewHTTPClientWithTimeout(120 * time.Second),
}
})
return globalAmazonDownloader
}
// waitForRateLimit implements rate limiting similar to PC version
// Max 9 requests per minute with 7 second delay between requests
func (a *AmazonDownloader) waitForRateLimit() {
amazonRateLimitMu.Lock()
defer amazonRateLimitMu.Unlock()
// downloadFromAfkarXYZ downloads a track using AfkarXYZ API
// Returns: downloadURL, fileName, error
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
// AfkarXYZ API endpoint
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
now := time.Now()
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
if now.Sub(a.apiCallResetTime) >= time.Minute {
a.apiCallCount = 0
a.apiCallResetTime = now
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", "", fmt.Errorf("failed to create request: %w", err)
}
// If we've hit the limit (9 requests per minute), wait until next minute
if a.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
if waitTime > 0 {
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
a.apiCallCount = 0
a.apiCallResetTime = time.Now()
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := a.client.Do(req)
if err != nil {
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
}
// Add delay between requests (7 seconds like PC version)
if !a.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(a.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("failed to read response: %w", err)
}
a.lastAPICallTime = time.Now()
a.apiCallCount++
var apiResp AfkarXYZResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return "", "", fmt.Errorf("failed to decode response: %w", err)
}
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"
}
// Sanitize filename
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
}
// GetAvailableAPIs returns list of available DoubleDouble regions
// 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))
// Step 1: Submit download request with rate limiting
encodedURL := url.QueryEscape(amazonURL)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
// Apply rate limiting before request (like PC version)
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)
}
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
@@ -416,7 +188,7 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
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
}
@@ -434,8 +206,7 @@ type AmazonDownloadResult struct {
ISRC string
}
// downloadFromAmazon downloads a track using the request parameters
// Uses DoubleDouble service (same as PC version)
// downloadFromAmazon uses AfkarXYZ API to download from Amazon Music
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
@@ -447,8 +218,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
var availability *TrackAvailability
var err error
if strings.HasPrefix(req.SpotifyID, "deezer:") {
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" {
@@ -471,21 +241,15 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
}
// Download using DoubleDouble service (same as PC)
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
// Download using AfkarXYZ API
downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL)
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
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", req.TrackName, req.ArtistName)
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
@@ -532,11 +296,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
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)
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
@@ -552,8 +311,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
}
// Embed metadata using Spotify data (more accurate than DoubleDouble)
// But preserve track/disc numbers from file if they were better
// Embed metadata using Spotify data
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
@@ -564,6 +322,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
// Use cover data from parallel fetch
@@ -574,22 +335,37 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
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)
}
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Amazon] Lyrics embedded successfully")
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed" // default
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
GoLog("[Amazon] Lyrics embedded successfully\n")
}
}
} 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)
if err != nil {
+3 -14
View File
@@ -8,18 +8,15 @@ import (
"strings"
)
// Spotify image size codes (same as PC version)
const (
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
spotifySize300 = "ab67616d00001e02"
spotifySize640 = "ab67616d0000b273"
spotifySizeMax = "ab67616d000082c1"
)
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
// Same logic as PC version for consistency
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
@@ -27,8 +24,6 @@ func convertSmallToMedium(imageURL string) string {
return imageURL
}
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
// This avoids file permission issues on Android
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
if coverURL == "" {
return nil, fmt.Errorf("no cover URL provided")
@@ -90,8 +85,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return data, nil
}
// upgradeToMaxQuality upgrades cover URL to maximum quality
// Supports both Spotify and Deezer CDNs
func upgradeToMaxQuality(coverURL string) string {
// Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) {
@@ -106,9 +99,6 @@ func upgradeToMaxQuality(coverURL string) string {
return coverURL
}
// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800)
// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg
// Available sizes: 56, 250, 500, 1000, 1400, 1800
func upgradeDeezerCover(coverURL string) string {
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return coverURL
@@ -122,7 +112,6 @@ func upgradeDeezerCover(coverURL string) string {
return upgraded
}
// GetCoverFromSpotify gets cover URL from Spotify metadata
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
return ""
+437 -95
View File
@@ -25,13 +25,12 @@ const (
deezerMaxParallelISRC = 10
)
// DeezerClient handles Deezer API interactions (no auth required)
type DeezerClient struct {
httpClient *http.Client
searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry
isrcCache map[string]string // trackID -> ISRC cache
isrcCache map[string]string
cacheMu sync.RWMutex
}
@@ -40,7 +39,6 @@ var (
deezerClientOnce sync.Once
)
// GetDeezerClient returns singleton Deezer client
func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{
@@ -54,7 +52,6 @@ func GetDeezerClient() *DeezerClient {
return deezerClient
}
// Deezer API response types
type deezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -63,7 +60,7 @@ type deezerTrack struct {
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
Link string `json:"link"`
ReleaseDate string `json:"release_date"` // Sometimes at track level
ReleaseDate string `json:"release_date"`
Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"`
@@ -86,8 +83,8 @@ type deezerAlbumSimple struct {
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` // Sometimes at album level
RecordType string `json:"record_type"` // album, single, ep, compile
ReleaseDate string `json:"release_date"`
RecordType string `json:"record_type"`
}
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
@@ -132,16 +129,25 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
}
}
type deezerGenre struct {
ID int `json:"id"`
Name string `json:"name"`
}
type deezerAlbumFull 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"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"` // album, single, ep, compile
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"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"`
Label string `json:"label"`
Genres struct {
Data []deezerGenre `json:"data"`
} `json:"genres"`
Artist deezerArtist `json:"artist"`
Contributors []deezerArtist `json:"contributors"`
Tracks struct {
@@ -176,12 +182,41 @@ type deezerPlaylistFull struct {
} `json:"tracks"`
}
// SearchAll searches for tracks and artists on Deezer
// 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) (*SearchAllResult, error) {
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
// filter can be: "" (all), "track", "artist", "album", "playlist"
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
albumLimit := 5 // Same as artistLimit for consistency
playlistLimit := 5
// When filter is specified, increase limits for that type only
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()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
@@ -192,73 +227,194 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
c.cacheMu.RUnlock()
result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0),
Artists: make([]SearchArtistResult, 0),
Tracks: make([]TrackMetadata, 0, trackLimit),
Artists: make([]SearchArtistResult, 0, artistLimit),
Albums: make([]SearchAlbumResult, 0, albumLimit),
Playlists: make([]SearchPlaylistResult, 0, playlistLimit),
}
// Search tracks - NO ISRC fetch for performance
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
if trackLimit > 0 {
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
var trackResp struct {
Data []deezerTrack `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
GoLog("[Deezer] Track search failed: %v\n", err)
return nil, fmt.Errorf("deezer track search failed: %w", err)
}
var trackResp struct {
Data []deezerTrack `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
GoLog("[Deezer] Track search failed: %v\n", err)
return nil, fmt.Errorf("deezer track search failed: %w", err)
}
if trackResp.Error != nil {
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)
}
if trackResp.Error != nil {
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)
}
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 {
// Convert directly without fetching ISRC - much faster
result.Tracks = append(result.Tracks, c.convertTrack(track))
for _, track := range trackResp.Data {
result.Tracks = append(result.Tracks, c.convertTrack(track))
}
}
// Search artists
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
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,
})
}
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)
}
} else {
GoLog("[Deezer] Artist search failed: %v\n", err)
}
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
// Search albums
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)
}
}
// Search playlists
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))
// Cache result
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
@@ -283,7 +439,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
}, nil
}
// GetAlbum fetches album with tracks
// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
c.cacheMu.RLock()
@@ -310,28 +465,80 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
artistName = strings.Join(names, ", ")
}
// Extract genres as comma-separated string
var genres []string
for _, g := range album.Genres.Data {
if g.Name != "" {
genres = append(genres, g.Name)
}
}
genreStr := strings.Join(genres, ", ")
info := AlbumInfoMetadata{
TotalTracks: album.NbTracks,
Name: album.Title,
ReleaseDate: album.ReleaseDate,
Artists: artistName,
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
Images: albumImage,
Genre: genreStr, // From Deezer album
Label: album.Label, // From Deezer album
}
// Fetch ISRCs in parallel
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
// Fetch all tracks with pagination (Deezer default limit is 25)
allTracks := album.Tracks.Data
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
// If album has more tracks than returned, fetch remaining pages
if album.NbTracks > len(allTracks) {
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))
// Normalize record_type (Deezer uses "compile" instead of "compilation")
albumType := album.RecordType
if albumType == "compile" {
albumType = "compilation"
}
for _, track := range album.Tracks.Data {
for i, track := range allTracks {
trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr]
// Use track position from API, fallback to index+1 if not provided
trackNum := track.TrackPosition
if trackNum == 0 {
trackNum = i + 1
}
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name,
@@ -341,7 +548,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
DurationMS: track.Duration * 1000,
Images: albumImage,
ReleaseDate: album.ReleaseDate,
TrackNumber: track.TrackPosition,
TrackNumber: trackNum,
TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
@@ -366,7 +573,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
return result, nil
}
// GetArtist fetches artist with albums
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
@@ -452,8 +658,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
return result, nil
}
// GetPlaylist fetches playlist with tracks
// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
@@ -476,11 +680,45 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
info.Owner.Name = playlist.Title
info.Owner.Images = playlistImage
// Fetch ISRCs in parallel
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
// Fetch all tracks with pagination (Deezer default limit is 25)
allTracks := playlist.Tracks.Data
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
for _, track := range playlist.Tracks.Data {
// If playlist has more tracks than returned, fetch remaining pages
if playlist.NbTracks > len(allTracks) {
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
if albumImage == "" {
albumImage = track.Album.CoverBig
@@ -515,15 +753,11 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
}, nil
}
// SearchByISRC searches for a track by ISRC using direct endpoint
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
// Use direct ISRC endpoint (API 2.0)
// https://api.deezer.com/2.0/track/isrc:{ISRC}
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
var track deezerTrack
if err := c.getJSON(ctx, directURL, &track); err != nil {
// Fallback to search if direct endpoint fails
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
var resp struct {
Data []deezerTrack `json:"data"`
@@ -557,13 +791,24 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
result := make(map[string]string)
result := make(map[string]string, len(tracks))
var resultMu sync.Mutex
var tracksToFetch []deezerTrack
var directISRCs map[string]string
c.cacheMu.RLock()
for _, track := range tracks {
trackIDStr := fmt.Sprintf("%d", track.ID)
if track.ISRC != "" {
result[trackIDStr] = track.ISRC
if _, ok := c.isrcCache[trackIDStr]; !ok {
if directISRCs == nil {
directISRCs = make(map[string]string)
}
directISRCs[trackIDStr] = track.ISRC
}
continue
}
if isrc, ok := c.isrcCache[trackIDStr]; ok {
result[trackIDStr] = isrc
} else {
@@ -571,6 +816,13 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
}
}
c.cacheMu.RUnlock()
if len(directISRCs) > 0 {
c.cacheMu.Lock()
for trackIDStr, isrc := range directISRCs {
c.isrcCache[trackIDStr] = isrc
}
c.cacheMu.Unlock()
}
if len(tracksToFetch) == 0 {
return result
@@ -585,7 +837,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
go func(t deezerTrack) {
defer wg.Done()
// Acquire semaphore
select {
case sem <- struct{}{}:
defer func() { <-sem }()
@@ -614,7 +865,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return result
}
// GetTrackISRC fetches ISRC for a single track (with caching)
// Use this when you need ISRC for download
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
c.cacheMu.RLock()
@@ -624,13 +874,11 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
}
c.cacheMu.RUnlock()
// Fetch from API
fullTrack, err := c.fetchFullTrack(ctx, trackID)
if err != nil {
return "", err
}
// Cache the result
c.cacheMu.Lock()
c.isrcCache[trackID] = fullTrack.ISRC
c.cacheMu.Unlock()
@@ -677,6 +925,101 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
return album.Cover
}
type AlbumExtendedMetadata struct {
Genre string // Comma-separated list of genres
Label string // Record label name
}
// Uses the album ID from a track to fetch extended metadata
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
if albumID == "" {
return nil, fmt.Errorf("empty album ID")
}
cacheKey := fmt.Sprintf("album_meta:%s", albumID)
c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*AlbumExtendedMetadata), nil
}
c.cacheMu.RUnlock()
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
var album deezerAlbumFull
if err := c.getJSON(ctx, albumURL, &album); err != nil {
return nil, fmt.Errorf("failed to fetch album: %w", err)
}
var genres []string
for _, g := range album.Genres.Data {
if g.Name != "" {
genres = append(genres, g.Name)
}
}
result := &AlbumExtendedMetadata{
Genre: strings.Join(genres, ", "),
Label: album.Label,
}
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
}
c.cacheMu.Unlock()
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
return result, nil
}
// GetTrackAlbumID fetches the album ID for a Deezer track
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
var track deezerTrack
if err := c.getJSON(ctx, trackURL, &track); err != nil {
return "", err
}
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) {
albumID, err := c.GetTrackAlbumID(ctx, trackID)
if err != nil {
return nil, fmt.Errorf("failed to get album ID: %w", err)
}
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) {
if isrc == "" {
return nil, fmt.Errorf("empty ISRC")
}
// First, search for track by ISRC
track, err := c.SearchByISRC(ctx, isrc)
if err != nil {
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:")
if deezerID == "" {
return nil, fmt.Errorf("track found but no Deezer ID")
}
// Then fetch extended metadata using the Deezer track ID
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
}
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
@@ -721,7 +1064,6 @@ func parseDeezerURL(input string) (string, string, error) {
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
// Skip language prefix if present (e.g., /en/, /fr/)
if len(parts) > 0 && len(parts[0]) == 2 {
parts = parts[1:]
}
-6
View File
@@ -158,7 +158,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
return "", false
}
// Use index for fast lookup
idx := GetISRCIndex(outputDir)
filePath, exists := idx.lookup(isrc)
if !exists {
@@ -175,7 +174,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
}
// CheckISRCExists is the exported version for gomobile (returns string, error)
// Returns the filepath if exists, empty string if not
func CheckISRCExists(outputDir, isrc string) (string, error) {
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
return filepath, nil
@@ -199,9 +197,6 @@ type FileExistenceResult struct {
ArtistName string `json:"artist_name,omitempty"`
}
// CheckFilesExistParallel checks if multiple files exist in parallel
// It builds an ISRC index from the output directory once, then checks all tracks against it
// Same implementation as PC version for consistency
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
var tracks []struct {
ISRC string `json:"isrc"`
@@ -266,7 +261,6 @@ func PreBuildISRCIndex(outputDir string) error {
}
// AddToISRCIndex adds a new file to the ISRC index after successful download
// This avoids rebuilding the entire index
func AddToISRCIndex(outputDir, isrc, filePath string) {
if outputDir == "" || isrc == "" || filePath == "" {
return
+153 -120
View File
@@ -13,8 +13,6 @@ import (
"github.com/dop251/goja"
)
// ParseSpotifyURL parses and validates a Spotify URL
// Returns JSON with type (track/album/playlist) and ID
func ParseSpotifyURL(url string) (string, error) {
parsed, err := parseSpotifyURI(url)
if err != nil {
@@ -34,19 +32,14 @@ func ParseSpotifyURL(url string) (string, error) {
return string(jsonBytes), nil
}
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
func SetSpotifyAPICredentials(clientID, clientSecret string) {
SetSpotifyCredentials(clientID, clientSecret)
}
// CheckSpotifyCredentials checks if Spotify credentials are configured
// Returns true if credentials are available (custom or env vars)
func CheckSpotifyCredentials() bool {
return HasSpotifyCredentials()
}
// GetSpotifyMetadata fetches metadata from Spotify URL
// Returns JSON with track/album/playlist data
func GetSpotifyMetadata(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -68,8 +61,6 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
return string(jsonBytes), nil
}
// SearchSpotify searches for tracks on Spotify
// Returns JSON array of track results
func SearchSpotify(query string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
@@ -91,8 +82,6 @@ func SearchSpotify(query string, limit int) (string, error) {
return string(jsonBytes), nil
}
// SearchSpotifyAll searches for tracks and artists on Spotify
// Returns JSON with tracks and artists arrays
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
@@ -114,8 +103,6 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error)
return string(jsonBytes), nil
}
// CheckAvailability checks track availability on streaming services
// Returns JSON with availability info for Tidal, Qobuz, Amazon
func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -131,7 +118,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
return string(jsonBytes), nil
}
// DownloadRequest represents a download request from Flutter
type DownloadRequest struct {
ISRC string `json:"isrc"`
Service string `json:"service"`
@@ -143,48 +129,51 @@ type DownloadRequest struct {
CoverURL string `json:"cover_url"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
Quality string `json:"quality"`
EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"`
ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"` // Unique ID for progress tracking
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
// Enriched IDs from Odesli/song.link - used to skip search and directly fetch
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
ItemID string `json:"item_id"`
DurationMS int `json:"duration_ms"`
Source string `json:"source"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
LyricsMode string `json:"lyrics_mode,omitempty"`
}
// DownloadResponse represents the result of a download
type DownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
AlreadyExists bool `json:"already_exists,omitempty"`
// Actual quality info from the source
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
Service string `json:"service,omitempty"` // Actual service used (for fallback)
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
ISRC string `json:"isrc,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
// If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata)
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
Success bool `json:"success"`
Message string `json:"message"`
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
Service string `json:"service,omitempty"` // Actual service used (for fallback)
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
ISRC string `json:"isrc,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
}
// DownloadResult is a generic result type for all downloaders
type DownloadResult struct {
FilePath string
BitDepth int
@@ -198,9 +187,6 @@ type DownloadResult struct {
ISRC string
}
// DownloadTrack downloads a track from the specified service
// requestJSON is a JSON string of DownloadRequest
// Returns JSON string of DownloadResponse
func DownloadTrack(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
@@ -214,7 +200,6 @@ func DownloadTrack(requestJSON string) (string, error) {
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Add output directory to allowed download dirs for extensions
if req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
@@ -338,22 +323,18 @@ func DownloadTrack(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// DownloadWithFallback tries to download from services in order
// Starts with the preferred service from request, then tries others
func DownloadWithFallback(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
// Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Add output directory to allowed download dirs for extensions
if req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
@@ -510,47 +491,36 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return errorResponse("All services failed. Last error: " + lastErr.Error())
}
// GetDownloadProgress returns current download progress
func GetDownloadProgress() string {
progress := getProgress()
jsonBytes, _ := json.Marshal(progress)
return string(jsonBytes)
}
// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
func GetAllDownloadProgress() string {
return GetMultiProgress()
}
// InitItemProgress initializes progress tracking for a download item
func InitItemProgress(itemID string) {
StartItemProgress(itemID)
}
// FinishItemProgress marks a download item as complete and removes tracking
func FinishItemProgress(itemID string) {
CompleteItemProgress(itemID)
}
// ClearItemProgress removes progress tracking for a specific item
func ClearItemProgress(itemID string) {
RemoveItemProgress(itemID)
}
// CancelDownload cancels an in-progress download for the given item.
func CancelDownload(itemID string) {
cancelDownload(itemID)
}
// CleanupConnections closes idle HTTP connections
// Call this periodically during large batch downloads to prevent TCP exhaustion
func CleanupConnections() {
CloseIdleConnections()
}
// ReadFileMetadata reads metadata directly from a FLAC file
// Returns JSON with all embedded metadata (title, artist, album, track number, etc.)
// This is useful for displaying accurate metadata in the UI without relying on cached data
func ReadFileMetadata(filePath string) (string, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
@@ -590,12 +560,10 @@ func ReadFileMetadata(filePath string) (string, error) {
return string(jsonBytes), nil
}
// SetDownloadDirectory sets the default download directory
func SetDownloadDirectory(path string) error {
return setDownloadDir(path)
}
// CheckDuplicate checks if a file with the given ISRC exists
func CheckDuplicate(outputDir, isrc string) (string, error) {
existingFile, exists := CheckISRCExists(outputDir, isrc)
@@ -612,26 +580,18 @@ func CheckDuplicate(outputDir, isrc string) (string, error) {
return string(jsonBytes), nil
}
// CheckDuplicatesBatch checks multiple files for duplicates in parallel
// Uses ISRC index for fast lookup (builds index once, checks all tracks)
// tracksJSON format: [{"isrc": "...", "track_name": "...", "artist_name": "..."}, ...]
// Returns JSON array of results
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
return CheckFilesExistParallel(outputDir, tracksJSON)
}
// PreBuildDuplicateIndex pre-builds the ISRC index for a directory
// Call this when entering album/playlist screen for faster duplicate checking
func PreBuildDuplicateIndex(outputDir string) error {
return PreBuildISRCIndex(outputDir)
}
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
func InvalidateDuplicateIndex(outputDir string) {
InvalidateISRCCache(outputDir)
}
// BuildFilename builds a filename from template and metadata
func BuildFilename(template string, metadataJSON string) (string, error) {
var metadata map[string]interface{}
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
@@ -642,14 +602,10 @@ func BuildFilename(template string, metadataJSON string) (string, error) {
return filename, nil
}
// SanitizeFilename removes invalid characters from filename
func SanitizeFilename(filename string) string {
return sanitizeFilename(filename)
}
// FetchLyrics fetches lyrics for a track from LRCLIB
// Returns JSON with lyrics data
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
@@ -659,10 +615,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
}
result := map[string]interface{}{
"success": true,
"source": lyrics.Source,
"sync_type": lyrics.SyncType,
"lines": lyrics.Lines,
"success": true,
"source": lyrics.Source,
"sync_type": lyrics.SyncType,
"lines": lyrics.Lines,
"instrumental": lyrics.Instrumental,
}
jsonBytes, err := json.Marshal(result)
@@ -673,15 +630,16 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
return string(jsonBytes), nil
}
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
// First tries to extract from file, then falls back to fetching from internet
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
// If filePath is provided, ONLY check file - don't fallback to online
// This allows Flutter to distinguish between "from file" vs "from online"
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
return lyrics, nil
}
// File has no lyrics - return empty, let Flutter call again without filePath
return "", nil
}
client := NewLyricsClient()
@@ -691,11 +649,15 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
return "", err
}
// Return special marker for instrumental tracks
if lyricsData.Instrumental {
return "[instrumental:true]", nil
}
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
return lrcContent, nil
}
// EmbedLyricsToFile embeds lyrics into an existing FLAC file
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
err := EmbedLyrics(filePath, lyrics)
if err != nil {
@@ -711,9 +673,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
return string(jsonBytes), nil
}
// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks
// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service
// This runs in background and returns immediately
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
var tracks []struct {
ISRC string `json:"isrc"`
@@ -749,26 +708,20 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
return string(jsonBytes), nil
}
// GetTrackCacheSize returns the current track ID cache size
func GetTrackCacheSize() int {
return GetCacheSize()
}
// ClearTrackIDCache clears the track ID cache
func ClearTrackIDCache() {
ClearTrackCache()
}
// ==================== DEEZER API ====================
// SearchDeezerAll searches for tracks and artists on Deezer (no API key required)
// Returns JSON with tracks and artists arrays
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)
defer cancel()
client := GetDeezerClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
@@ -837,6 +790,37 @@ func ParseDeezerURLExport(url string) (string, error) {
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) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := GetDeezerClient()
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
if err != nil {
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
return "", err
}
result := map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// SearchDeezerByISRC searches for a track by ISRC on Deezer
func SearchDeezerByISRC(isrc string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -949,10 +933,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
}
// ==================== SONGLINK DEEZER SUPPORT ====================
// CheckAvailabilityFromDeezerID checks track availability using Deezer track ID as source
// Returns JSON with availability info for Spotify, Tidal, Amazon, etc.
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
@@ -1136,14 +1116,12 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
return "", err
}
// Initialize with saved settings
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
manager.InitializeExtension(ext.ID, settings)
}
// Return extension info as JSON
result := map[string]interface{}{
"id": ext.ID,
"display_name": ext.Manifest.DisplayName,
@@ -1290,7 +1268,22 @@ func CleanupExtensions() {
manager.UnloadAllExtensions()
}
// ==================== EXTENSION AUTH API ====================
// 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) {
manager := GetExtensionManager()
result, err := manager.InvokeAction(extensionID, actionName)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// GetExtensionPendingAuthJSON returns pending auth request for an extension
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
@@ -1371,9 +1364,6 @@ func GetAllPendingAuthRequestsJSON() (string, error) {
return string(jsonBytes), nil
}
// ==================== EXTENSION FFMPEG API ====================
// GetPendingFFmpegCommandJSON returns a pending FFmpeg command for Flutter to execute
func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
cmd := GetPendingFFmpegCommand(commandID)
if cmd == nil {
@@ -1433,7 +1423,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
// Extension not found, return original track
return trackJSON, nil
}
@@ -1537,10 +1526,6 @@ func GetSearchProvidersJSON() (string, error) {
return string(jsonBytes), nil
}
// ==================== EXTENSION URL HANDLER ====================
// HandleURLWithExtensionJSON tries to handle a URL with any matching extension
// Returns JSON with type, tracks, album info, etc.
func HandleURLWithExtensionJSON(url string) (string, error) {
manager := GetExtensionManager()
resultWithID, err := manager.HandleURLWithExtension(url)
@@ -1723,6 +1708,11 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
if trackCover == "" {
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{}{
"id": track.ID,
"name": track.Name,
@@ -1732,7 +1722,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
"duration_ms": track.DurationMS,
"cover_url": trackCover,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"track_number": trackNum,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
@@ -1745,6 +1735,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
"artist_id": album.ArtistID,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
@@ -1802,7 +1793,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
return "", fmt.Errorf("failed to marshal result: %w", err)
}
// Parse into album metadata (same structure)
var album ExtAlbumMetadata
if err := json.Unmarshal(jsonBytes, &album); err != nil {
return "", fmt.Errorf("failed to parse playlist: %w", err)
@@ -1903,7 +1893,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
response["header_image"] = artist.HeaderImage
}
// Add listeners if present
if artist.Listeners > 0 {
response["listeners"] = artist.Listeners
}
@@ -1961,9 +1950,6 @@ func GetURLHandlersJSON() (string, error) {
return string(jsonBytes), nil
}
// ==================== EXTENSION POST-PROCESSING ====================
// RunPostProcessingJSON runs post-processing hooks on a file
func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
var metadata map[string]interface{}
if metadataJSON != "" {
@@ -2019,8 +2005,6 @@ func GetPostProcessingProvidersJSON() (string, error) {
return string(jsonBytes), nil
}
// ==================== EXTENSION STORE ====================
// InitExtensionStoreJSON initializes the extension store with cache directory
func InitExtensionStoreJSON(cacheDir string) error {
InitExtensionStore(cacheDir)
@@ -2034,7 +2018,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
return "", fmt.Errorf("extension store not initialized")
}
// Force refresh if requested
if forceRefresh {
store.FetchRegistry(true)
}
@@ -2115,3 +2098,53 @@ func ClearStoreCacheJSON() error {
store.ClearCache()
return nil
}
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
return extension.%s();
}
return null;
})()
`, functionName, functionName)
result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout)
if err != nil {
return "", fmt.Errorf("%s failed: %w", functionName, err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return "", fmt.Errorf("%s returned null", functionName)
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
return "", fmt.Errorf("failed to marshal result: %w", err)
}
return string(jsonBytes), nil
}
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
}
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
}
+81 -45
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension management functionality
package gobackend
import (
@@ -15,8 +14,6 @@ import (
"github.com/dop251/goja"
)
// compareVersions compares two semantic version strings
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
func compareVersions(v1, v2 string) int {
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
@@ -46,11 +43,11 @@ func compareVersions(v1, v2 string) int {
return 0
}
// LoadedExtension represents an extension that has been loaded into memory
type LoadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
@@ -71,7 +68,6 @@ var (
globalExtManagerOnce sync.Once
)
// GetExtensionManager returns the global extension manager instance
func GetExtensionManager() *ExtensionManager {
globalExtManagerOnce.Do(func() {
globalExtManager = &ExtensionManager{
@@ -81,7 +77,6 @@ func GetExtensionManager() *ExtensionManager {
return globalExtManager
}
// SetDirectories sets the extensions and data directories
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -99,9 +94,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
return nil
}
// LoadExtensionFromFile loads an extension from a .spotiflac-ext file
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
@@ -180,14 +173,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return nil, fmt.Errorf("failed to create extension directory: %w", err)
}
// Extract all files (preserving directory structure)
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
// Preserve relative path within the zip (support subdirectories)
// Clean the path to prevent path traversal attacks
relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
@@ -245,7 +235,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return ext, nil
}
// initializeVM creates and initializes the Goja VM for an extension
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
vm := goja.New()
ext.VM = vm
@@ -322,7 +311,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return nil
}
// GetExtension returns a loaded extension by ID
// Returns error if extension not found (gomobile compatible)
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
m.mu.RLock()
@@ -347,7 +335,6 @@ func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
return result
}
// SetExtensionEnabled enables or disables an extension
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -408,7 +395,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
return loaded, errors
}
// loadExtensionFromDirectory loads an extension from an already extracted directory
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -497,7 +483,6 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
return nil
}
// UpgradeExtension upgrades an existing extension from a new package file
// Only allows upgrades (new version > current version), not downgrades
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
// Validate file extension
@@ -644,7 +629,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return ext, nil
}
// ExtensionUpgradeInfo holds information about extension upgrade check
type ExtensionUpgradeInfo struct {
ExtensionID string `json:"extension_id"`
CurrentVersion string `json:"current_version"`
@@ -716,7 +700,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
return info, nil
}
// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
info, err := m.checkExtensionUpgradeInternal(filePath)
if err != nil {
@@ -736,27 +719,28 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions()
type ExtensionInfo struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"`
Types []ExtensionType `json:"types"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
Error string `json:"error_message,omitempty"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"quality_options,omitempty"`
Permissions []string `json:"permissions"`
HasMetadataProvider bool `json:"has_metadata_provider"`
HasDownloadProvider bool `json:"has_download_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"`
Types []ExtensionType `json:"types"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
Error string `json:"error_message,omitempty"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"quality_options,omitempty"`
Permissions []string `json:"permissions"`
HasMetadataProvider bool `json:"has_metadata_provider"`
HasDownloadProvider bool `json:"has_download_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
infos := make([]ExtensionInfo, len(extensions))
@@ -813,6 +797,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
SearchBehavior: ext.Manifest.SearchBehavior,
TrackMatching: ext.Manifest.TrackMatching,
PostProcessing: ext.Manifest.PostProcessing,
Capabilities: ext.Manifest.Capabilities,
}
}
@@ -826,7 +811,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
// ==================== Extension Lifecycle ====================
// InitializeExtension calls the extension's initialize method with settings
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -888,7 +872,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return nil
}
// CleanupExtension calls the extension's cleanup method
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -899,10 +882,9 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
}
if ext.VM == nil {
return nil // No VM, nothing to cleanup
return nil
}
// Call cleanup function
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
@@ -951,11 +933,65 @@ func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Unlock()
for _, id := range extensionIDs {
// Call cleanup first
m.CleanupExtension(id)
// Then unload
m.UnloadExtension(id)
}
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) {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return nil, fmt.Errorf("extension not found: %s", extensionID)
}
if ext.VM == nil {
return nil, fmt.Errorf("extension VM not initialized")
}
if !ext.Enabled {
return nil, fmt.Errorf("extension is disabled")
}
// Call the action function on the extension object
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
// Handle promise - return pending status
return { success: true, pending: true, message: 'Action started' };
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: false, error: 'Action function not found: %s' };
})()
`, actionName, actionName, actionName)
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
if err != nil {
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
return nil, fmt.Errorf("action failed: %v", err)
}
if result == nil || goja.IsUndefined(result) {
return map[string]interface{}{"success": true}, nil
}
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap)
return resultMap, nil
}
return map[string]interface{}{"success": true, "result": exported}, nil
}
+43 -43
View File
@@ -23,6 +23,7 @@ const (
SettingTypeNumber SettingType = "number"
SettingTypeBool SettingType = "boolean"
SettingTypeSelect SettingType = "select"
SettingTypeButton SettingType = "button" // Action button that calls a JS function
)
// ExtensionPermissions defines what resources an extension can access
@@ -42,6 +43,7 @@ type ExtensionSetting struct {
Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
}
// QualityOption represents a quality option for download providers
@@ -64,15 +66,23 @@ type QualitySpecificSetting struct {
Options []string `json:"options,omitempty"` // For select type
}
// SearchFilter defines a filter option for search
type SearchFilter struct {
ID string `json:"id"` // Filter identifier (e.g., "track", "album", "artist", "playlist")
Label string `json:"label,omitempty"` // Display label (e.g., "Songs", "Albums", "Artists", "Playlists")
Icon string `json:"icon,omitempty"` // Optional icon name
}
// SearchBehaviorConfig defines custom search behavior for an extension
type SearchBehaviorConfig struct {
Enabled bool `json:"enabled"` // Whether extension provides custom search
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab
Icon string `json:"icon,omitempty"` // Icon for search tab
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3)
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
Enabled bool `json:"enabled"` // Whether extension provides custom search
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab
Icon string `json:"icon,omitempty"` // Icon for search tab
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3)
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
Filters []SearchFilter `json:"filters,omitempty"` // Available search filters (e.g., track, album, artist, playlist)
}
// URLHandlerConfig defines custom URL handling for an extension
@@ -105,24 +115,25 @@ type PostProcessingConfig struct {
// ExtensionManifest represents the manifest.json of an extension
type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
}
// ManifestValidationError represents a validation error in the manifest
@@ -149,9 +160,7 @@ func ParseManifest(data []byte) (*ExtensionManifest, error) {
return &manifest, nil
}
// Validate checks if the manifest has all required fields and valid values
func (m *ExtensionManifest) Validate() error {
// Check required fields
if strings.TrimSpace(m.Name) == "" {
return &ManifestValidationError{Field: "name", Message: "name is required"}
}
@@ -172,7 +181,6 @@ func (m *ExtensionManifest) Validate() error {
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
}
// Validate extension types
for _, t := range m.Types {
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
return &ManifestValidationError{
@@ -198,20 +206,6 @@ func (m *ExtensionManifest) Validate() error {
}
}
// Validate setting type
validTypes := map[SettingType]bool{
SettingTypeString: true,
SettingTypeNumber: true,
SettingTypeBool: true,
SettingTypeSelect: true,
}
if !validTypes[setting.Type] {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].type", i),
Message: fmt.Sprintf("invalid setting type: %s", setting.Type),
}
}
// Select type requires options
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
return &ManifestValidationError{
@@ -219,6 +213,13 @@ func (m *ExtensionManifest) Validate() error {
Message: "select type requires options",
}
}
if setting.Type == SettingTypeButton && setting.Action == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].action", i),
Message: "button type requires action (JS function name)",
}
}
}
return nil
@@ -289,7 +290,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
return false
}
// Parse URL to get host
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
for _, pattern := range m.URLHandler.Patterns {
pattern = strings.ToLower(strings.TrimSpace(pattern))
+132
View File
@@ -2,6 +2,7 @@
package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -38,6 +39,10 @@ type ExtTrackMetadata struct {
DeezerID string `json:"deezer_id,omitempty"`
SpotifyID string `json:"spotify_id,omitempty"`
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
// Extended metadata from enrichment (can come from Deezer, Spotify, etc.)
Label string `json:"label,omitempty"` // Record label
Copyright string `json:"copyright,omitempty"` // Copyright information
Genre string `json:"genre,omitempty"` // Music genre(s)
}
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
@@ -53,6 +58,7 @@ type ExtAlbumMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
ArtistID string `json:"artist_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TotalTracks int `json:"total_tracks"`
@@ -144,6 +150,10 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Call extension's searchTracks function
script := fmt.Sprintf(`
(function() {
@@ -206,6 +216,10 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') {
@@ -252,6 +266,10 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
@@ -301,6 +319,10 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') {
@@ -349,6 +371,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return track, nil // Extension disabled, return as-is
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Convert track to JSON for passing to JS
trackJSON, err := json.Marshal(track)
if err != nil {
@@ -415,6 +441,10 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
@@ -460,6 +490,10 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') {
@@ -508,6 +542,10 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Set up progress callback in VM
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
@@ -758,6 +796,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if enrichedTrack.Artists != "" {
req.ArtistName = enrichedTrack.Artists
}
// Copy extended metadata from enrichment (label, copyright, genre, release_date)
if enrichedTrack.Label != "" && req.Label == "" {
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
req.Label = enrichedTrack.Label
}
if enrichedTrack.Copyright != "" && req.Copyright == "" {
GoLog("[DownloadWithExtensionFallback] Copyright from enrichment: %s\n", enrichedTrack.Copyright)
req.Copyright = enrichedTrack.Copyright
}
if enrichedTrack.Genre != "" && req.Genre == "" {
GoLog("[DownloadWithExtensionFallback] Genre from enrichment: %s\n", enrichedTrack.Genre)
req.Genre = enrichedTrack.Genre
}
if enrichedTrack.ReleaseDate != "" && req.ReleaseDate == "" {
GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate)
req.ReleaseDate = enrichedTrack.ReleaseDate
}
}
}
}
@@ -795,6 +850,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: req.Source,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
// Embed genre and label if provided (from Deezer metadata)
if req.Genre != "" || req.Label != "" {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
}
// If extension has skipMetadataEnrichment, copy metadata
@@ -878,10 +945,44 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerID) {
// For built-in providers, enrich with Deezer metadata if not already present
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
deezerClient := GetDeezerClient()
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
GoLog("[DownloadWithExtensionFallback] Genre from Deezer: %s\n", req.Genre)
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
}
} else if err != nil {
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
}
// Use built-in provider
result, err := tryBuiltInProvider(providerID, req)
if err == nil && result.Success {
result.Service = providerID
// Copy enriched metadata to response for Flutter (needed for M4A->FLAC conversion)
if req.Label != "" {
result.Label = req.Label
}
if req.Copyright != "" {
result.Copyright = req.Copyright
}
if req.Genre != "" {
result.Genre = req.Genre
}
if req.ReleaseDate != "" && result.ReleaseDate == "" {
result.ReleaseDate = req.ReleaseDate
}
return result, nil
}
if err != nil {
@@ -935,6 +1036,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: providerID,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
// Embed genre and label if provided (from Deezer metadata)
if req.Genre != "" || req.Label != "" {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
}
// If extension has skipMetadataEnrichment and returned metadata, use it
@@ -1085,6 +1198,9 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}, nil
}
@@ -1120,6 +1236,10 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Convert options to JSON
optionsJSON, _ := json.Marshal(options)
@@ -1191,6 +1311,10 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') {
@@ -1272,6 +1396,10 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
sourceJSON, _ := json.Marshal(sourceTrack)
candidatesJSON, _ := json.Marshal(candidates)
@@ -1335,6 +1463,10 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata)
script := fmt.Sprintf(`
+5 -25
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension runtime with sandboxed execution
package gobackend
import (
@@ -17,7 +16,6 @@ var (
extensionAuthStateMu sync.RWMutex
)
// ExtensionAuthState holds auth state for an extension
type ExtensionAuthState struct {
PendingAuthURL string
AuthCode string
@@ -30,7 +28,6 @@ type ExtensionAuthState struct {
PKCEChallenge string
}
// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL
type PendingAuthRequest struct {
ExtensionID string
AuthURL string
@@ -55,7 +52,6 @@ func ClearPendingAuthRequest(extensionID string) {
delete(pendingAuthRequests, extensionID)
}
// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback)
func SetExtensionAuthCode(extensionID string, authCode string) {
extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock()
@@ -68,7 +64,6 @@ func SetExtensionAuthCode(extensionID string, authCode string) {
state.AuthCode = authCode
}
// SetExtensionTokens sets access/refresh tokens for an extension
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock()
@@ -84,7 +79,6 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
state.IsAuthenticated = accessToken != ""
}
// ExtensionRuntime provides sandboxed APIs for extensions
type ExtensionRuntime struct {
extensionID string
manifest *ExtensionManifest
@@ -95,7 +89,6 @@ type ExtensionRuntime struct {
vm *goja.Runtime
}
// NewExtensionRuntime creates a new runtime for an extension
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
jar, _ := newSimpleCookieJar()
@@ -108,7 +101,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
vm: ext.VM,
}
// Create HTTP client with redirect validation to prevent SSRF via open redirect
client := &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
@@ -119,7 +111,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain}
}
// Also block redirects to private/local networks (SSRF protection)
if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
@@ -136,7 +127,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
return runtime
}
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
type RedirectBlockedError struct {
Domain string
IsPrivate bool
@@ -162,10 +152,10 @@ func isPrivateIP(host string) bool {
"172.24.", "172.25.", "172.26.", "172.27.",
"172.28.", "172.29.", "172.30.", "172.31.",
"192.168.",
"169.254.", // Link-local
"::1", // IPv6 localhost
"fc00:", // IPv6 private
"fe80:", // IPv6 link-local
"169.254.",
"::1",
"fc00:",
"fe80:",
}
hostLower := host
@@ -183,7 +173,6 @@ func isPrivateIP(host string) bool {
return false
}
// simpleCookieJar is a simple in-memory cookie jar
type simpleCookieJar struct {
cookies map[string][]*http.Cookie
mu sync.RWMutex
@@ -208,7 +197,6 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
return j.cookies[u.Host]
}
// SetSettings updates the runtime settings
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings
}
@@ -228,7 +216,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
httpObj.Set("clearCookies", r.httpClearCookies)
vm.Set("http", httpObj)
// Storage API
storageObj := vm.NewObject()
storageObj.Set("get", r.storageGet)
storageObj.Set("set", r.storageSet)
@@ -243,7 +230,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
credentialsObj.Set("has", r.credentialsHas)
vm.Set("credentials", credentialsObj)
// Auth API (for OAuth and other auth flows)
authObj := vm.NewObject()
authObj.Set("openAuthUrl", r.authOpenUrl)
authObj.Set("getAuthCode", r.authGetCode)
@@ -270,7 +256,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
fileObj.Set("getSize", r.fileGetSize)
vm.Set("file", fileObj)
// FFmpeg API (for post-processing)
ffmpegObj := vm.NewObject()
ffmpegObj.Set("execute", r.ffmpegExecute)
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
@@ -284,7 +269,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
matchingObj.Set("normalizeString", r.matchingNormalizeString)
vm.Set("matching", matchingObj)
// Utilities
utilsObj := vm.NewObject()
utilsObj.Set("base64Encode", r.base64Encode)
utilsObj.Set("base64Decode", r.base64Decode)
@@ -299,6 +283,7 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
vm.Set("utils", utilsObj)
// Log object (already set in extension_manager.go, but we can enhance it)
@@ -309,7 +294,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
logObj.Set("error", r.logError)
vm.Set("log", logObj)
// Go backend functions
gobackendObj := vm.NewObject()
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
vm.Set("gobackend", gobackendObj)
@@ -320,16 +304,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
// Global fetch() - Promise-style HTTP API (browser-compatible)
vm.Set("fetch", r.fetchPolyfill)
// Global atob/btoa - Base64 encoding (browser-compatible)
vm.Set("atob", r.atobPolyfill)
vm.Set("btoa", r.btoaPolyfill)
// TextEncoder/TextDecoder constructors
r.registerTextEncoderDecoder(vm)
// URL class for URL parsing
r.registerURLClass(vm)
// JSON global (browser-compatible)
r.registerJSONGlobal(vm)
}
+1 -28
View File
@@ -18,7 +18,6 @@ import (
// ==================== Auth API (OAuth Support) ====================
// authOpenUrl requests Flutter to open an OAuth URL
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -33,7 +32,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
callbackURL = call.Arguments[1].String()
}
// Store pending auth request for Flutter to pick up
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
}
pendingAuthRequestsMu.Unlock()
// Update auth state
extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
@@ -50,7 +47,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
extensionAuthState[r.extensionID] = state
}
state.PendingAuthURL = authURL
state.AuthCode = "" // Clear any previous auth code
state.AuthCode = ""
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
@@ -61,7 +58,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
})
}
// authGetCode gets the auth code (set by Flutter after OAuth callback)
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -114,7 +110,6 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true)
}
// authClear clears all auth state for the extension
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.Lock()
delete(extensionAuthState, r.extensionID)
@@ -138,7 +133,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(false)
}
// Check if token is expired
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
return r.vm.ToValue(false)
}
@@ -146,7 +140,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(state.IsAuthenticated)
}
// authGetTokens returns current tokens (for extension to use in API calls)
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -182,16 +175,13 @@ func generatePKCEVerifier(length int) (string, error) {
length = 128
}
// Generate random bytes
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
// Use base64url encoding without padding (RFC 7636 compliant)
verifier := base64.RawURLEncoding.EncodeToString(bytes)
// Trim to exact length
if len(verifier) > length {
verifier = verifier[:length]
}
@@ -199,15 +189,12 @@ func generatePKCEVerifier(length int) (string, error) {
return verifier, nil
}
// generatePKCEChallenge generates a code challenge from verifier using S256 method
func generatePKCEChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
// Base64url encode without padding (RFC 7636)
return base64.RawURLEncoding.EncodeToString(hash[:])
}
// authGeneratePKCE generates a PKCE code verifier and challenge pair
// Returns: { verifier: string, challenge: string, method: "S256" }
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
// Default length is 64 characters
length := 64
@@ -227,7 +214,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
challenge := generatePKCEChallenge(verifier)
// Store in auth state for later use
extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
@@ -247,7 +233,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
})
}
// authGetPKCE returns the current PKCE verifier and challenge (if generated)
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -405,7 +390,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Get stored PKCE verifier
extensionAuthStateMu.RLock()
state, exists := extensionAuthState[r.extensionID]
var verifier string
@@ -421,7 +405,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Validate domain
if err := r.validateDomain(tokenURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -429,7 +412,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Build token request body
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("client_id", clientID)
@@ -439,14 +421,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
formData.Set("redirect_uri", redirectURI)
}
// Add extra params
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
for k, v := range extraParams {
formData.Set(k, fmt.Sprintf("%v", v))
}
}
// Make token request
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -475,7 +455,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Parse response
var tokenResp map[string]interface{}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -485,7 +464,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Check for error in response
if errMsg, ok := tokenResp["error"].(string); ok {
errDesc, _ := tokenResp["error_description"].(string)
return r.vm.ToValue(map[string]interface{}{
@@ -495,7 +473,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Extract tokens
accessToken, _ := tokenResp["access_token"].(string)
refreshToken, _ := tokenResp["refresh_token"].(string)
expiresIn, _ := tokenResp["expires_in"].(float64)
@@ -508,7 +485,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Store tokens in auth state
extensionAuthStateMu.Lock()
state, exists = extensionAuthState[r.extensionID]
if !exists {
@@ -521,14 +497,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
if expiresIn > 0 {
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
}
// Clear PKCE after successful exchange
state.PKCEVerifier = ""
state.PKCEChallenge = ""
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
// Return full token response
result := map[string]interface{}{
"success": true,
"access_token": accessToken,
@@ -538,7 +512,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
if expiresIn > 0 {
result["expires_in"] = expiresIn
}
// Include any additional fields from response
if scope, ok := tokenResp["scope"].(string); ok {
result["scope"] = scope
}
-6
View File
@@ -31,14 +31,12 @@ var (
ffmpegCommandID int64
)
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
ffmpegCommandsMu.RLock()
defer ffmpegCommandsMu.RUnlock()
return ffmpegCommands[commandID]
}
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock()
@@ -50,14 +48,12 @@ func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg str
}
}
// ClearFFmpegCommand removes a completed FFmpeg command
func ClearFFmpegCommand(commandID string) {
ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock()
delete(ffmpegCommands, commandID)
}
// ffmpegExecute queues an FFmpeg command for execution by Flutter
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -118,7 +114,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
}
}
// ffmpegGetInfo gets audio file information using FFprobe
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -147,7 +142,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
})
}
// ffmpegConvert is a helper for common conversion operations
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
+1 -39
View File
@@ -21,8 +21,6 @@ var (
allowedDownloadDirsMu sync.RWMutex
)
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
// This should be called by the Go backend when setting up download paths
func SetAllowedDownloadDirs(dirs []string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
@@ -30,7 +28,6 @@ func SetAllowedDownloadDirs(dirs []string) {
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
}
// AddAllowedDownloadDir adds a directory to the allowed list
func AddAllowedDownloadDir(dir string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
@@ -40,7 +37,6 @@ func AddAllowedDownloadDir(dir string) {
}
}
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
func isPathInAllowedDirs(absPath string) bool {
allowedDownloadDirsMu.RLock()
defer allowedDownloadDirsMu.RUnlock()
@@ -62,36 +58,28 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
}
// Clean and resolve the path
cleanPath := filepath.Clean(path)
// SECURITY: Block absolute paths by default
// Only allow if path is in explicitly allowed download directories
if filepath.IsAbs(cleanPath) {
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Check if path is in allowed download directories
if isPathInAllowedDirs(absPath) {
return absPath, nil
}
// Block all other absolute paths
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
}
// For relative paths, join with data directory (extension's sandbox)
fullPath := filepath.Join(r.dataDir, cleanPath)
// Resolve to absolute path
absPath, err := filepath.Abs(fullPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Ensure path is within data directory (prevent path traversal)
absDataDir, _ := filepath.Abs(r.dataDir)
if !strings.HasPrefix(absPath, absDataDir) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
@@ -100,8 +88,6 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return absPath, nil
}
// fileDownload downloads a file from URL to the specified path
// Supports progress callback via options.onProgress
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -113,7 +99,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String()
outputPath := call.Arguments[1].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -121,7 +106,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// Validate output path (allows absolute paths for download queue)
fullPath, err := r.validatePath(outputPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -130,20 +114,17 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// Get options if provided
var onProgress goja.Callable
var headers map[string]string
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
optionsObj := call.Arguments[2].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
// Extract headers
if h, ok := opts["headers"].(map[string]interface{}); ok {
headers = make(map[string]string)
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
// Extract onProgress callback
if progressVal, ok := opts["onProgress"]; ok {
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
onProgress = callable
@@ -152,7 +133,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
}
// Create directory if needed
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -161,7 +141,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// Create HTTP request
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -170,7 +149,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// Set headers
for k, v := range headers {
req.Header.Set(k, v)
}
@@ -178,7 +156,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
}
// Download file
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -195,7 +172,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// Create output file
out, err := os.Create(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -205,12 +181,10 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
defer out.Close()
// Get content length for progress
contentLength := resp.ContentLength
// Copy content with progress reporting
var written int64
buf := make([]byte, 32*1024) // 32KB buffer
buf := make([]byte, 32*1024)
for {
nr, er := resp.Body.Read(buf)
if nr > 0 {
@@ -235,7 +209,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// Report progress
if onProgress != nil && contentLength > 0 {
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
}
@@ -260,7 +233,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
})
}
// fileExists checks if a file exists in the sandbox
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
@@ -276,7 +248,6 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(err == nil)
}
// fileDelete deletes a file in the sandbox
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -306,7 +277,6 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
})
}
// fileRead reads a file from the sandbox
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -338,7 +308,6 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
})
}
// fileWrite writes data to a file in the sandbox
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -380,7 +349,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
})
}
// fileCopy copies a file within the sandbox
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -408,7 +376,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
})
}
// Read source file
data, err := os.ReadFile(fullSrc)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -417,7 +384,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
})
}
// Create destination directory if needed
dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -426,7 +392,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
})
}
// Write to destination
if err := os.WriteFile(fullDst, data, 0644); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -440,7 +405,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
})
}
// fileMove moves/renames a file within the sandbox
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -468,7 +432,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
})
}
// Create destination directory if needed
dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -490,7 +453,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
})
}
// fileGetSize returns the size of a file in bytes
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
-16
View File
@@ -52,7 +52,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
@@ -60,7 +59,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
})
}
// Get headers if provided
headers := make(map[string]string)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export()
@@ -71,7 +69,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}
}
// Create request
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -97,7 +94,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -134,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
@@ -175,7 +170,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}
}
// Create request
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -204,7 +198,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -231,8 +224,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
})
}
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
// Usage: http.request(url, options) where options = { method, body, headers }
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -242,7 +233,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
@@ -326,7 +316,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -354,7 +343,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
})
}
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call)
}
@@ -364,7 +352,6 @@ func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call)
}
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call)
}
@@ -380,7 +367,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
@@ -465,7 +451,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -492,7 +477,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
})
}
// httpClearCookies clears all cookies for this extension
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
jar.mu.Lock()
-4
View File
@@ -143,19 +143,16 @@ func (r *ExtensionRuntime) getSaltPath() string {
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath()
// Try to read existing salt
salt, err := os.ReadFile(saltPath)
if err == nil && len(salt) == 32 {
return salt, nil
}
// Generate new random salt (32 bytes)
salt = make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err)
}
// Save salt to file
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
return nil, fmt.Errorf("failed to save salt: %w", err)
}
@@ -214,7 +211,6 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
return err
}
// Encrypt the data
key, err := r.getEncryptionKey()
if err != nil {
return fmt.Errorf("failed to get encryption key: %w", err)
+26 -3
View File
@@ -12,6 +12,7 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/dop251/goja"
)
@@ -94,7 +95,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue([]byte{})
}
// Get key - can be string or array of bytes
var keyBytes []byte
keyArg := call.Arguments[0].Export()
switch k := keyArg.(type) {
@@ -113,7 +113,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue([]byte{})
}
// Get message - can be string or array of bytes
var msgBytes []byte
msgArg := call.Arguments[1].Export()
switch m := msgArg.(type) {
@@ -136,7 +135,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
mac.Write(msgBytes)
result := mac.Sum(nil)
// Convert to array of numbers for JavaScript
jsArray := make([]interface{}, len(result))
for i, b := range result {
jsArray[i] = int(b)
@@ -268,6 +266,11 @@ 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 {
return r.vm.ToValue(getRandomUserAgent())
}
// ==================== Logging Functions ====================
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
@@ -369,4 +372,24 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
})
// Expose getLocalTime - returns device local time info
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
now := time.Now()
_, offsetSeconds := now.Zone()
offsetMinutes := offsetSeconds / 60
return vm.ToValue(map[string]interface{}{
"year": now.Year(),
"month": int(now.Month()),
"day": now.Day(),
"hour": now.Hour(),
"minute": now.Minute(),
"second": now.Second(),
"weekday": int(now.Weekday()),
"offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC
"timezone": now.Location().String(),
"timestamp": now.Unix(),
})
})
}
-4
View File
@@ -42,7 +42,6 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
return fmt.Errorf("failed to create settings directory: %w", err)
}
// Load all existing settings
return s.loadAllSettings()
}
@@ -99,7 +98,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
settingsPath := s.getSettingsPath(extensionID)
// Create directory if needed
dir := filepath.Dir(settingsPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
@@ -160,7 +158,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
s.settings[extensionID][key] = value
// Persist to disk
return s.saveSettings(extensionID, s.settings[extensionID])
}
@@ -198,7 +195,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
delete(s.settings, extensionID)
// Remove settings file
settingsPath := s.getSettingsPath(extensionID)
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
return err
-2
View File
@@ -35,7 +35,6 @@ type StoreExtension struct {
Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"`
// Alternative camelCase fields (for flexibility)
DisplayNameAlt string `json:"displayName,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"`
@@ -332,7 +331,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
// Create destination file
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
-4
View File
@@ -6,10 +6,8 @@ import (
"strings"
)
// Invalid filename characters for Android/Windows/Linux
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(filename string) string {
sanitized := invalidChars.ReplaceAllString(filename, "_")
@@ -30,7 +28,6 @@ func sanitizeFilename(filename string) string {
return sanitized
}
// buildFilenameFromTemplate builds a filename from template and metadata
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
if template == "" {
template = "{artist} - {title}"
@@ -91,7 +88,6 @@ func formatDiscNumber(n int) string {
return fmt.Sprintf("%d", n)
}
// extractYear extracts year from date string (YYYY-MM-DD or YYYY)
func extractYear(date string) string {
if len(date) >= 4 {
return date[:4]
+12 -6
View File
@@ -1,23 +1,29 @@
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 (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0
github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4
golang.org/x/net v0.49.0
)
require (
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
golang.org/x/mod v0.31.0 // indirect
github.com/klauspost/compress v1.17.4 // 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/text v0.3.8 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.org/x/sys 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/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/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
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/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
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/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
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/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+8 -54
View File
@@ -15,67 +15,30 @@ import (
"time"
)
// HTTP utility functions for consistent request handling across all downloaders
// getRandomUserAgent generates a random Windows Chrome User-Agent string
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
// Uses modern Chrome format with build and patch numbers
// Windows 11 still reports as "Windows NT 10.0" for compatibility
func getRandomUserAgent() string {
winMajor := rand.Intn(2) + 10
chromeVersion := rand.Intn(25) + 100
chromeBuild := rand.Intn(1500) + 3000
chromePatch := rand.Intn(65) + 60
// Chrome version 120-145 (modern range)
chromeVersion := rand.Intn(26) + 120
chromeBuild := rand.Intn(1500) + 6000
chromePatch := rand.Intn(200) + 100
return fmt.Sprintf(
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
winMajor,
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
chromeVersion,
chromeBuild,
chromePatch,
)
}
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
// Alternative format matching referensi/backend/spotify_metadata.go exactly
// func getRandomMacUserAgent() string {
// macMajor := rand.Intn(4) + 11 // macOS 11-14
// macMinor := rand.Intn(5) + 4 // Minor 4-8
// webkitMajor := rand.Intn(7) + 530
// webkitMinor := rand.Intn(7) + 30
// chromeMajor := rand.Intn(25) + 80
// chromeBuild := rand.Intn(1500) + 3000
// chromePatch := rand.Intn(65) + 60
// safariMajor := rand.Intn(7) + 530
// safariMinor := rand.Intn(6) + 30
//
// return fmt.Sprintf(
// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
// macMajor,
// macMinor,
// webkitMajor,
// webkitMinor,
// chromeMajor,
// chromeBuild,
// chromePatch,
// safariMajor,
// safariMinor,
// )
// }
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
// func getRandomDesktopUserAgent() string {
// if rand.Intn(2) == 0 {
// return getRandomUserAgent() // Windows
// }
// return getRandomMacUserAgent() // Mac
// }
const (
DefaultTimeout = 60 * time.Second
DownloadTimeout = 120 * time.Second
SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second
Second = time.Second // Exported for use in other files
)
// Shared transport with connection pooling to prevent TCP exhaustion
@@ -107,7 +70,6 @@ var downloadClient = &http.Client{
Timeout: DownloadTimeout,
}
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{
Transport: sharedTransport,
@@ -128,7 +90,6 @@ func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
}
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
// Also checks for ISP blocking on errors
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
@@ -147,7 +108,6 @@ type RetryConfig struct {
BackoffFactor float64
}
// DefaultRetryConfig returns default retry configuration
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: DefaultMaxRetries,
@@ -253,13 +213,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
}
// calculateNextDelay calculates the next delay with exponential backoff
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
return min(nextDelay, config.MaxDelay)
}
// getRetryAfterDuration parses Retry-After header and returns duration
// Returns 60 seconds as default if header is missing or invalid
func getRetryAfterDuration(resp *http.Response) time.Duration {
retryAfter := resp.Header.Get("Retry-After")
@@ -302,7 +260,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
return body, nil
}
// ValidateResponse checks if response is valid (non-nil, status 2xx)
func ValidateResponse(resp *http.Response) error {
if resp == nil {
return fmt.Errorf("response is nil")
@@ -331,7 +288,6 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
return msg
}
// ISPBlockingError represents an error caused by ISP blocking
type ISPBlockingError struct {
Domain string
Reason string
@@ -447,7 +403,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
return nil
}
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
// Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL)
@@ -485,7 +440,6 @@ func extractDomain(rawURL string) string {
return "unknown"
}
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
// If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil {
+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 -6
View File
@@ -8,7 +8,6 @@ import (
"time"
)
// LogEntry represents a single log entry
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
@@ -16,7 +15,6 @@ type LogEntry struct {
Message string `json:"message"`
}
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
type LogBuffer struct {
entries []LogEntry
maxSize int
@@ -41,7 +39,6 @@ func GetLogBuffer() *LogBuffer {
return globalLogBuffer
}
// SetLoggingEnabled enables or disables logging
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock()
defer lb.mu.Unlock()
@@ -55,7 +52,6 @@ func (lb *LogBuffer) IsLoggingEnabled() bool {
return lb.loggingEnabled
}
// Add adds a log entry to the buffer
func (lb *LogBuffer) Add(level, tag, message string) {
lb.mu.Lock()
defer lb.mu.Unlock()
@@ -154,11 +150,11 @@ func GoLog(format string, args ...interface{}) {
// Determine level from message content
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"
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "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"
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
level = "DEBUG"
+71 -40
View File
@@ -6,6 +6,8 @@ import (
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -13,13 +15,9 @@ import (
"time"
)
// ========================================
// Lyrics Cache with TTL
// ========================================
const (
lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours
durationToleranceSec = 10.0 // Duration matching tolerance in seconds
lyricsCacheTTL = 24 * time.Hour
durationToleranceSec = 10.0
)
type lyricsCacheEntry struct {
@@ -37,10 +35,8 @@ var globalLyricsCache = &lyricsCache{
}
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
// Normalize key: lowercase, trim spaces
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
// Round duration to nearest 10 seconds for cache key
roundedDuration := math.Round(durationSec/10) * 10
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
}
@@ -55,7 +51,6 @@ func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsRes
return nil, false
}
// Check if expired
if time.Now().After(entry.expiresAt) {
return nil, false
}
@@ -74,7 +69,6 @@ func (c *lyricsCache) Set(artist, track string, durationSec float64, response *L
}
}
// CleanExpired removes expired entries from cache
func (c *lyricsCache) CleanExpired() int {
c.mu.Lock()
defer c.mu.Unlock()
@@ -90,7 +84,6 @@ func (c *lyricsCache) CleanExpired() int {
return cleaned
}
// Size returns current cache size
func (c *lyricsCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
@@ -130,9 +123,7 @@ type LyricsClient struct {
func NewLyricsClient() *LyricsClient {
return &LyricsClient{
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
}
}
@@ -172,8 +163,6 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
return c.parseLRCLibResponse(&lrcResp), nil
}
// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching
// durationSec: track duration in seconds, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
baseURL := "https://lrclib.net/api/search"
params := url.Values{}
@@ -206,13 +195,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
return nil, fmt.Errorf("no lyrics found")
}
// Filter and score results based on duration matching and synced lyrics
bestMatch := c.findBestMatch(results, durationSec)
if bestMatch != nil {
return c.parseLRCLibResponse(bestMatch), nil
}
// Fallback: return first result with synced lyrics
for _, result := range results {
if result.SyncedLyrics != "" {
return c.parseLRCLibResponse(&result), nil
@@ -222,7 +209,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
return c.parseLRCLibResponse(&results[0]), nil
}
// findBestMatch finds the best matching lyrics based on duration and sync status
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse
@@ -230,11 +216,9 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
for i := range results {
result := &results[i]
// Check duration match if target duration is provided
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
if durationMatches {
// Prefer synced lyrics over plain
if result.SyncedLyrics != "" && bestSynced == nil {
bestSynced = result
} else if result.PlainLyrics != "" && bestPlain == nil {
@@ -243,23 +227,23 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
}
}
// Return synced first, then plain
if bestSynced != nil {
return bestSynced
}
return bestPlain
}
// durationMatches checks if two durations are within tolerance
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
diff := math.Abs(lrcDuration - targetDuration)
return diff <= durationToleranceSec
}
// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching
// 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) {
// Check cache first
// Normalize artist name - take first artist before comma/semicolon for better matching
primaryArtist := normalizeArtistName(artistName)
// Check cache first (use original artist name for cache key)
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached
@@ -270,29 +254,44 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
var lyrics *LyricsResponse
var err error
// Try exact match first
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
// Helper to check if lyrics result is valid (has lines OR is instrumental)
isValidResult := func(l *LyricsResponse) bool {
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
}
// Try exact match first with primary artist
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
// Try with full artist name if different from primary
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
}
}
// Try with simplified track name
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
// Search with duration matching
query := artistName + " " + trackName
// Search with duration matching (use primary artist for search)
query := primaryArtist + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
@@ -300,9 +299,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
// Search with simplified name and duration matching
if simplifiedTrack != trackName {
query = artistName + " " + simplifiedTrack
query = primaryArtist + " " + simplifiedTrack
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)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
@@ -394,7 +393,6 @@ func msToLRCTimestamp(ms int64) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
}
// convertToLRC converts lyrics to LRC format string (without metadata headers)
// Use convertToLRCWithMetadata for full LRC with headers
// Kept for potential future use
// func convertToLRC(lyrics *LyricsResponse) string {
@@ -421,8 +419,6 @@ func msToLRCTimestamp(ms int64) string {
// return builder.String()
// }
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
// Includes [ti:], [ar:], [by:] headers
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
if lyrics == nil || len(lyrics.Lines) == 0 {
return ""
@@ -430,13 +426,11 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
var builder strings.Builder
// Add metadata headers
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
builder.WriteString("\n")
// Add lyrics lines
if lyrics.SyncType == "LINE_SYNCED" {
for _, line := range lyrics.Lines {
if line.Words == "" {
@@ -485,3 +479,40 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result)
}
// normalizeArtistName extracts the primary artist from multi-artist strings
// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX"
// e.g., "Artist1; Artist2" -> "Artist1"
func normalizeArtistName(name string) string {
// Split by common separators: ", " or "; " or " & " or " feat. " or " ft. "
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) {
if lrcContent == "" {
return "", fmt.Errorf("empty LRC content")
}
dir := filepath.Dir(audioFilePath)
ext := filepath.Ext(audioFilePath)
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
lrcFilePath := filepath.Join(dir, baseName+".lrc")
if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil {
return "", fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC file: %s\n", lrcFilePath)
return lrcFilePath, nil
}
+435 -98
View File
@@ -1,7 +1,10 @@
package gobackend
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"os"
"strconv"
"strings"
@@ -11,7 +14,6 @@ import (
"github.com/go-flac/go-flac"
)
// Metadata represents track metadata for embedding
type Metadata struct {
Title string
Artist string
@@ -24,9 +26,11 @@ type Metadata struct {
ISRC string
Description string
Lyrics string
Genre string
Label string
Copyright string
}
// EmbedMetadata embeds metadata into a FLAC file
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -82,6 +86,18 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
}
if metadata.Genre != "" {
setComment(cmt, "GENRE", metadata.Genre)
}
if metadata.Label != "" {
setComment(cmt, "ORGANIZATION", metadata.Label)
}
if metadata.Copyright != "" {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
@@ -123,8 +139,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
return f.Save(filePath)
}
// EmbedMetadataWithCoverData embeds metadata into a FLAC file with cover data as bytes
// This avoids file permission issues on Android by not requiring a temp file
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -180,6 +194,18 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
}
if metadata.Genre != "" {
setComment(cmt, "GENRE", metadata.Genre)
}
if metadata.Label != "" {
setComment(cmt, "ORGANIZATION", metadata.Label)
}
if metadata.Copyright != "" {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
@@ -310,7 +336,6 @@ func fileExists(path string) bool {
return err == nil
}
// EmbedLyrics embeds lyrics into a FLAC file as a separate operation
func EmbedLyrics(filePath string, lyrics string) error {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -348,6 +373,51 @@ func EmbedLyrics(filePath string, lyrics string) error {
return f.Save(filePath)
}
func EmbedGenreLabel(filePath string, genre, label string) error {
if genre == "" && label == "" {
return nil
}
f, err := flac.ParseFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
}
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
if genre != "" {
setComment(cmt, "GENRE", genre)
}
if label != "" {
setComment(cmt, "ORGANIZATION", label)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
} else {
f.Meta = append(f.Meta, &cmtBlock)
}
return f.Save(filePath)
}
// ExtractLyrics extracts embedded lyrics from a FLAC file
func ExtractLyrics(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
@@ -377,16 +447,12 @@ func ExtractLyrics(filePath string) (string, error) {
return "", fmt.Errorf("no lyrics found in file")
}
// AudioQuality represents audio quality info from a FLAC file
type AudioQuality struct {
BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"`
}
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
// For M4A files, it delegates to GetM4AQuality
func GetAudioQuality(filePath string) (AudioQuality, error) {
file, err := os.Open(filePath)
if err != nil {
@@ -452,93 +518,170 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
data, err := os.ReadFile(filePath)
input, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to read M4A file: %w", err)
return fmt.Errorf("failed to open M4A file: %w", err)
}
defer input.Close()
moovPos := findAtom(data, "moov", 0)
if moovPos < 0 {
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")
}
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
udtaPos := findAtom(data, "udta", moovPos+8)
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 newData []byte
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
metaPos := findAtom(data, "meta", udtaPos+8)
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
}
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
newData = append(newData, data[:metaPos]...)
newData = append(newData, metaAtom...)
newData = append(newData, data[metaPos+metaSize:]...)
} else {
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
newUdtaSize := 8 + len(newUdtaContent)
newUdta := make([]byte, 4)
newUdta[0] = byte(newUdtaSize >> 24)
newUdta[1] = byte(newUdtaSize >> 16)
newUdta[2] = byte(newUdtaSize >> 8)
newUdta[3] = byte(newUdtaSize)
newUdta = append(newUdta, []byte("udta")...)
newUdta = append(newUdta, newUdtaContent...)
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")
}
newData = append(newData, data[:udtaPos]...)
newData = append(newData, newUdta...)
newData = append(newData, data[udtaPos+udtaSize:]...)
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)
}
} else {
udtaContent := metaAtom
udtaSize := 8 + len(udtaContent)
newUdta := make([]byte, 4)
newUdta[0] = byte(udtaSize >> 24)
newUdta[1] = byte(udtaSize >> 16)
newUdta[2] = byte(udtaSize >> 8)
newUdta[3] = byte(udtaSize)
newUdta = append(newUdta, []byte("udta")...)
newUdta = append(newUdta, udtaContent...)
}()
insertPos := moovPos + moovSize
newData = append(newData, data[:insertPos]...)
newData = append(newData, newUdta...)
newData = append(newData, data[insertPos:]...)
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
}
}
newMoovSize := moovSize + len(newData) - len(data)
newData[moovPos] = byte(newMoovSize >> 24)
newData[moovPos+1] = byte(newMoovSize >> 16)
newData[moovPos+2] = byte(newMoovSize >> 8)
newData[moovPos+3] = byte(newMoovSize)
if err := os.WriteFile(filePath, newData, 0644); err != nil {
return fmt.Errorf("failed to write M4A file: %w", 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
}
// findAtom finds an atom by name starting from offset
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
@@ -615,7 +758,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
return metaAtom
}
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
func buildTextAtom(name, value string) []byte {
valueBytes := []byte(value)
@@ -667,7 +809,6 @@ func buildTrackNumberAtom(track, total int) []byte {
return atom
}
// buildDiscNumberAtom builds disk atom
func buildDiscNumberAtom(disc, total int) []byte {
dataAtom := []byte{
0, 0, 0, 22, // size
@@ -693,9 +834,9 @@ func buildDiscNumberAtom(disc, total int) []byte {
// buildCoverAtom builds covr atom with image data
func buildCoverAtom(coverData []byte) []byte {
imageType := byte(13) // default JPEG
imageType := byte(13)
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
imageType = 14 // PNG
imageType = 14
}
dataSize := 16 + len(coverData)
@@ -705,8 +846,8 @@ func buildCoverAtom(coverData []byte) []byte {
dataAtom[2] = byte(dataSize >> 8)
dataAtom[3] = byte(dataSize)
dataAtom = append(dataAtom, []byte("data")...)
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, 0, 0, 0, imageType)
dataAtom = append(dataAtom, 0, 0, 0, 0)
dataAtom = append(dataAtom, coverData...)
atomSize := 8 + len(dataAtom)
@@ -721,30 +862,226 @@ func buildCoverAtom(coverData []byte) []byte {
return atom
}
// GetM4AQuality reads audio quality from M4A file
func GetM4AQuality(filePath string) (AudioQuality, error) {
data, err := os.ReadFile(filePath)
f, err := os.Open(filePath)
if err != nil {
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err)
}
defer f.Close()
moovPos := findAtom(data, "moov", 0)
if moovPos < 0 {
info, err := f.Stat()
if err != nil {
return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err)
}
fileSize := info.Size()
moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil {
return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err)
}
if !moovFound {
return AudioQuality{}, fmt.Errorf("moov atom not found")
}
for i := moovPos; i < len(data)-20; i++ {
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
if i+24 < len(data) {
sampleRate := int(data[i+22])<<8 | int(data[i+23])
bitDepth := 16
if string(data[i:i+4]) == "alac" {
bitDepth = 24
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
}
}
moovStart := moovHeader.offset
moovEnd := moovHeader.offset + moovHeader.size
sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize)
if err != nil {
return AudioQuality{}, err
}
return AudioQuality{}, fmt.Errorf("audio info not found in M4A file")
buf := make([]byte, 24)
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
}
sampleRate := int(buf[22])<<8 | int(buf[23])
bitDepth := 16
if atomType == "alac" {
bitDepth = 24
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
}
type atomHeader struct {
offset int64
size int64
headerSize int64
typ string
}
func readAtomHeaderAt(f *os.File, offset, fileSize int64) (atomHeader, error) {
if offset+8 > fileSize {
return atomHeader{}, io.ErrUnexpectedEOF
}
headerBuf := make([]byte, 8)
if _, err := f.ReadAt(headerBuf, offset); err != nil {
return atomHeader{}, err
}
size32 := binary.BigEndian.Uint32(headerBuf[0:4])
typ := string(headerBuf[4:8])
if size32 == 1 {
if offset+16 > fileSize {
return atomHeader{}, io.ErrUnexpectedEOF
}
extBuf := make([]byte, 8)
if _, err := f.ReadAt(extBuf, offset+8); err != nil {
return atomHeader{}, err
}
size64 := binary.BigEndian.Uint64(extBuf)
return atomHeader{offset: offset, size: int64(size64), headerSize: 16, typ: typ}, nil
}
return atomHeader{offset: offset, size: int64(size32), headerSize: 8, typ: typ}, nil
}
func findAtomInRange(f *os.File, start, size int64, target string, fileSize int64) (atomHeader, bool, error) {
if size <= 0 {
return atomHeader{}, false, nil
}
end := start + size
pos := start
for pos+8 <= end {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return atomHeader{}, false, err
}
atomSize := header.size
if atomSize == 0 {
atomSize = end - pos
}
if atomSize < header.headerSize {
return atomHeader{}, false, fmt.Errorf("invalid atom size for %s", header.typ)
}
header.size = atomSize
if header.typ == target {
return header, true, nil
}
pos += atomSize
}
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) {
const chunkSize = 64 * 1024
patternMP4A := []byte("mp4a")
patternALAC := []byte("alac")
var tail []byte
readPos := start
for readPos < end {
toRead := end - readPos
if toRead > chunkSize {
toRead = chunkSize
}
buf := make([]byte, toRead)
n, err := f.ReadAt(buf, readPos)
if err != nil && err != io.EOF {
return 0, "", fmt.Errorf("failed to read M4A atom data: %w", err)
}
if n == 0 {
break
}
data := append(tail, buf[:n]...)
mp4aIdx := bytes.Index(data, patternMP4A)
alacIdx := bytes.Index(data, patternALAC)
bestIdx := -1
bestType := ""
switch {
case mp4aIdx >= 0 && alacIdx >= 0:
if mp4aIdx <= alacIdx {
bestIdx = mp4aIdx
bestType = "mp4a"
} else {
bestIdx = alacIdx
bestType = "alac"
}
case mp4aIdx >= 0:
bestIdx = mp4aIdx
bestType = "mp4a"
case alacIdx >= 0:
bestIdx = alacIdx
bestType = "alac"
}
if bestIdx >= 0 {
absolute := readPos - int64(len(tail)) + int64(bestIdx)
if absolute+24 > fileSize {
return 0, "", fmt.Errorf("audio info not found in M4A file")
}
return absolute, bestType, nil
}
if len(data) >= 3 {
tail = append([]byte{}, data[len(data)-3:]...)
} else {
tail = append([]byte{}, data...)
}
readPos += int64(n)
}
return 0, "", fmt.Errorf("audio info not found in M4A file")
}
+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"
)
+54 -47
View File
@@ -6,11 +6,6 @@ import (
"time"
)
// ========================================
// ISRC to Track ID Cache
// ========================================
// TrackIDCacheEntry holds cached track ID with metadata
type TrackIDCacheEntry struct {
TidalTrackID int64
QobuzTrackID int64
@@ -18,11 +13,13 @@ type TrackIDCacheEntry struct {
ExpiresAt time.Time
}
// TrackIDCache caches ISRC to track ID mappings
type TrackIDCache struct {
cache map[string]*TrackIDCacheEntry
mu sync.RWMutex
ttl time.Duration
// Cleanup is triggered on writes at a fixed interval to avoid unbounded growth.
lastCleanup time.Time
cleanupInterval time.Duration
}
var (
@@ -30,30 +27,49 @@ var (
trackIDCacheOnce sync.Once
)
// GetTrackIDCache returns the global track ID cache
func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{
cache: make(map[string]*TrackIDCacheEntry),
ttl: 30 * time.Minute,
cache: make(map[string]*TrackIDCacheEntry),
ttl: 30 * time.Minute,
cleanupInterval: 5 * time.Minute,
}
})
return globalTrackIDCache
}
// Get retrieves a cached entry by ISRC
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
c.mu.RLock()
defer c.mu.RUnlock()
entry, exists := c.cache[isrc]
if !exists || time.Now().After(entry.ExpiresAt) {
if !exists {
c.mu.RUnlock()
return nil
}
return entry
expired := time.Now().After(entry.ExpiresAt)
c.mu.RUnlock()
if !expired {
return entry
}
// Lazily delete expired 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)
}
}
}
// SetTidal caches Tidal track ID for an ISRC
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -64,10 +80,15 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.cache[isrc] = entry
}
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
}
}
// SetQobuz caches Qobuz track ID for an ISRC
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -78,10 +99,15 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.cache[isrc] = entry
}
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
}
}
// SetAmazon caches Amazon track ID for an ISRC
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -92,27 +118,27 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
c.cache[isrc] = entry
}
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
}
}
// Clear removes all cached entries
func (c *TrackIDCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.cache = make(map[string]*TrackIDCacheEntry)
}
// Size returns the number of cached entries
func (c *TrackIDCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
// ========================================
// Parallel Download Helper
// ========================================
// ParallelDownloadResult holds results from parallel operations
type ParallelDownloadResult struct {
CoverData []byte
@@ -122,9 +148,6 @@ type ParallelDownloadResult struct {
LyricsErr error
}
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
// This runs while the main audio download is happening
// durationMs: track duration in milliseconds for lyrics matching
func FetchCoverAndLyricsParallel(
coverURL string,
maxQualityCover bool,
@@ -153,7 +176,6 @@ func FetchCoverAndLyricsParallel(
}()
}
// Fetch lyrics in parallel
if embedLyrics {
wg.Add(1)
go func() {
@@ -180,11 +202,6 @@ func FetchCoverAndLyricsParallel(
return result
}
// ========================================
// Pre-warm Cache for Album/Playlist
// ========================================
// PreWarmCacheRequest represents a track to pre-warm cache for
type PreWarmCacheRequest struct {
ISRC string
TrackName string
@@ -193,8 +210,6 @@ type PreWarmCacheRequest struct {
Service string // "tidal", "qobuz", "amazon"
}
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
// This runs in background while user is viewing the track list
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
if len(requests) == 0 {
return
@@ -214,8 +229,8 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
wg.Add(1)
go func(r PreWarmCacheRequest) {
defer wg.Done()
semaphore <- struct{}{} // Acquire
defer func() { <-semaphore }() // Release
semaphore <- struct{}{}
defer func() { <-semaphore }()
switch r.Service {
case "tidal":
@@ -259,12 +274,6 @@ func preWarmAmazonCache(isrc, spotifyID string) {
}
}
// ========================================
// Exported Functions for Flutter
// ========================================
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest
@@ -272,13 +281,11 @@ func PreWarmCache(tracksJSON string) error {
return nil
}
// ClearTrackCache clears the track ID cache
func ClearTrackCache() {
GetTrackIDCache().Clear()
fmt.Println("[Cache] Track ID cache cleared")
}
// GetCacheSize returns the current cache size
func GetCacheSize() int {
return GetTrackIDCache().Size()
}
+4 -20
View File
@@ -6,8 +6,6 @@ import (
"time"
)
// DownloadProgress represents current download progress
// Now unified - returns data from multi-progress system
type DownloadProgress struct {
CurrentFile string `json:"current_file"`
Progress float64 `json:"progress"`
@@ -15,21 +13,19 @@ type DownloadProgress struct {
BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"`
IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed"
Status string `json:"status"`
}
// ItemProgress represents progress for a single download item
type ItemProgress struct {
ItemID string `json:"item_id"`
BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"`
Progress float64 `json:"progress"` // 0.0 to 1.0
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
Progress float64 `json:"progress"`
SpeedMBps float64 `json:"speed_mbps"`
IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed"
Status string `json:"status"`
}
// MultiProgress holds progress for multiple concurrent downloads
type MultiProgress struct {
Items map[string]*ItemProgress `json:"items"`
}
@@ -38,12 +34,10 @@ var (
downloadDir string
downloadDirMu sync.RWMutex
// Multi-download progress tracking (unified system)
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
multiMu sync.RWMutex
)
// getProgress returns current download progress from multi-progress system
func getProgress() DownloadProgress {
multiMu.RLock()
defer multiMu.RUnlock()
@@ -62,7 +56,6 @@ func getProgress() DownloadProgress {
return DownloadProgress{}
}
// GetMultiProgress returns progress for all active downloads as JSON
func GetMultiProgress() string {
multiMu.RLock()
defer multiMu.RUnlock()
@@ -74,7 +67,6 @@ func GetMultiProgress() string {
return string(jsonBytes)
}
// GetItemProgress returns progress for a specific item as JSON
func GetItemProgress(itemID string) string {
multiMu.RLock()
defer multiMu.RUnlock()
@@ -201,14 +193,6 @@ func setDownloadDir(path string) error {
return nil
}
// getDownloadDir returns the default download directory
// Kept for potential future use
// func getDownloadDir() string {
// downloadDirMu.RLock()
// defer downloadDirMu.RUnlock()
// return downloadDir
// }
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
+158 -58
View File
@@ -17,7 +17,6 @@ import (
"time"
)
// QobuzDownloader handles Qobuz downloads
type QobuzDownloader struct {
client *http.Client
appID string
@@ -29,7 +28,6 @@ var (
qobuzDownloaderOnce sync.Once
)
// QobuzTrack represents a Qobuz track
type QobuzTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -50,7 +48,6 @@ type QobuzTrack struct {
} `json:"performer"`
}
// qobuzArtistsMatch checks if the artist names are similar enough
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
@@ -93,9 +90,7 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return false
}
// qobuzSplitArtists splits artist string by common separators
func qobuzSplitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|")
@@ -154,7 +149,6 @@ func qobuzSameWordsUnordered(a, b string) bool {
return true
}
// qobuzTitlesMatch checks if track titles are similar enough
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
@@ -164,12 +158,10 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Clean BOTH titles and compare (removes suffixes like remaster, remix, etc)
cleanExpected := qobuzCleanTitle(normExpected)
cleanFound := qobuzCleanTitle(normFound)
@@ -177,14 +169,12 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true
}
// Check if cleaned versions contain each other
if cleanExpected != "" && cleanFound != "" {
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
return true
}
}
// Extract core title (before any parentheses/brackets)
coreExpected := qobuzExtractCoreTitle(normExpected)
coreFound := qobuzExtractCoreTitle(normFound)
@@ -225,19 +215,15 @@ func qobuzExtractCoreTitle(title string) string {
return strings.TrimSpace(title[:cutIdx])
}
// qobuzCleanTitle removes common suffixes from track titles for comparison
func qobuzCleanTitle(title string) string {
cleaned := title
// Remove content in parentheses/brackets that are version indicators
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
versionPatterns := []string{
"remaster", "remastered", "deluxe", "bonus", "single",
"album version", "radio edit", "original mix", "extended",
"club mix", "remix", "live", "acoustic", "demo",
}
// Remove parenthetical content if it contains version indicators
for {
startParen := strings.LastIndex(cleaned, "(")
endParen := strings.LastIndex(cleaned, ")")
@@ -258,7 +244,6 @@ func qobuzCleanTitle(title string) string {
break
}
// Same for brackets
for {
startBracket := strings.LastIndex(cleaned, "[")
endBracket := strings.LastIndex(cleaned, "]")
@@ -279,7 +264,6 @@ func qobuzCleanTitle(title string) string {
break
}
// Remove trailing " - version" patterns
dashPatterns := []string{
" - remaster", " - remastered", " - single version", " - radio edit",
" - live", " - acoustic", " - demo", " - remix",
@@ -290,7 +274,6 @@ func qobuzCleanTitle(title string) string {
}
}
// Remove multiple spaces
for strings.Contains(cleaned, " ") {
cleaned = strings.ReplaceAll(cleaned, " ", " ")
}
@@ -350,7 +333,6 @@ func containsQueryQobuz(queries []string, query string) bool {
return false
}
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
func NewQobuzDownloader() *QobuzDownloader {
qobuzDownloaderOnce.Do(func() {
globalQobuzDownloader = &QobuzDownloader{
@@ -361,7 +343,6 @@ func NewQobuzDownloader() *QobuzDownloader {
return globalQobuzDownloader
}
// GetTrackByID fetches track info directly by Qobuz track ID
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
// Qobuz API: /track/get?track_id=XXX
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
@@ -394,10 +375,11 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
// Uses same APIs as PC version for compatibility
func (q *QobuzDownloader) GetAvailableAPIs() []string {
// Same APIs as PC version (referensi/backend/qobuz.go)
// Primary: dab.yeet.su, Fallback: dabmusic.xyz
// Primary: dab.yeet.su, Fallback: dabmusic.xyz, qobuz.squid.wtf
encodedAPIs := []string{
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId=
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId=
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=", // qobuz.squid.wtf/api/download-music?track_id=
}
var apis []string
@@ -412,7 +394,95 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
return apis
}
// SearchTrackByISRC searches for a track by ISRC
// mapJumoQuality maps Qobuz quality codes to Jumo format
func mapJumoQuality(quality string) int {
switch quality {
case "6":
return 6 // 16-bit FLAC
case "7":
return 7 // 24-bit 96kHz
case "27":
return 27 // 24-bit 192kHz
default:
return 6
}
}
// decodeXOR decodes XOR-encoded response from Jumo API
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)
}
// downloadFromJumo gets download URL from Jumo API (fallback)
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
formatID := mapJumoQuality(quality)
region := "US"
// Jumo API endpoint
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
// Try parsing as plain JSON first
if err := json.Unmarshal(body, &result); err != nil {
// Try XOR decoding
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)
}
}
// Check for URL in various response formats
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) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
@@ -455,7 +525,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
@@ -500,7 +569,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
if len(isrcMatches) > 0 {
// Verify duration if provided
if expectedDurationSec > 0 {
var durationVerifiedMatches []*QobuzTrack
for _, track := range isrcMatches {
@@ -508,7 +576,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 10 seconds tolerance
if durationDiff <= 10 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
@@ -520,14 +587,12 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return durationVerifiedMatches[0], nil
}
// ISRC matches but duration doesn't
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
isrc, expectedDurationSec, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
expectedDurationSec, isrcMatches[0].Duration)
}
// No duration to verify, return first match
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil
}
@@ -539,17 +604,14 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
return q.SearchTrackByISRCWithDuration(isrc, 0)
}
// SearchTrackByMetadata searches for a track using artist name and track name
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
}
// SearchTrackByMetadataWithDuration searches for a track with duration verification
// 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) {
@@ -688,34 +750,32 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
}
if len(durationMatches) > 0 {
// Return best quality among duration matches
for _, track := range durationMatches {
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)
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)
return durationMatches[0], nil
}
// No duration match found
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 {
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)
return track, nil
}
}
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)
return tracksToCheck[0], nil
}
@@ -731,8 +791,6 @@ type qobuzAPIResult struct {
duration time.Duration
}
// getQobuzDownloadURLParallel requests download URL from all APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available")
@@ -748,9 +806,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
go func(api string) {
reqStart := time.Now()
client := &http.Client{
Timeout: 15 * time.Second,
}
client := NewHTTPClientWithTimeout(15 * time.Second)
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
@@ -816,7 +872,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
for i := 0; i < len(apis); i++ {
result := <-resultChan
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) {
@@ -839,8 +895,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
}
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
apis := q.GetAvailableAPIs()
if len(apis) == 0 {
@@ -848,11 +902,35 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
}
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
if err != nil {
return "", err
if err == nil {
return downloadURL, nil
}
return downloadURL, nil
// All standard APIs failed, try Jumo as fallback
GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n")
jumoURL, jumoErr := q.downloadFromJumo(trackID, quality)
if jumoErr == nil {
return jumoURL, nil
}
// If quality is 27 (hi-res), try fallback to lower quality
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
@@ -938,7 +1016,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return nil
}
// QobuzDownloadResult contains download result with quality info
type QobuzDownloadResult struct {
FilePath string
BitDepth int
@@ -952,7 +1029,6 @@ type QobuzDownloadResult struct {
ISRC string
}
// downloadFromQobuz downloads a track using the request parameters
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
downloader := NewQobuzDownloader()
@@ -1110,16 +1186,25 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
albumName = req.AlbumName
}
// Use track number from request if available, otherwise from Qobuz API
actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 {
actualTrackNumber = track.TrackNumber
}
metadata := Metadata{
Title: track.Title,
Artist: track.Performer.Name,
Album: albumName,
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
Date: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
ISRC: track.ISRC,
Genre: req.Genre, // From Deezer album metadata
Label: req.Label, // From Deezer album metadata
Copyright: req.Copyright, // From Deezer album metadata
}
var coverData []byte
@@ -1132,13 +1217,28 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Qobuz] Lyrics embedded successfully")
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Qobuz] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
}
} else if req.EmbedLyrics {
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
@@ -1155,7 +1255,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Artist: track.Performer.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber,
TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
ISRC: track.ISRC,
}, nil
-8
View File
@@ -5,7 +5,6 @@ import (
"time"
)
// RateLimiter implements a sliding window rate limiter
type RateLimiter struct {
mu sync.Mutex
maxRequests int
@@ -13,7 +12,6 @@ type RateLimiter struct {
timestamps []time.Time
}
// NewRateLimiter creates a new rate limiter with specified max requests per window
func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
return &RateLimiter{
maxRequests: maxRequests,
@@ -22,8 +20,6 @@ func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
}
}
// WaitForSlot blocks until a request is allowed under the rate limit
// Returns immediately if under the limit, otherwise waits until a slot is available
func (r *RateLimiter) WaitForSlot() {
r.mu.Lock()
defer r.mu.Unlock()
@@ -70,8 +66,6 @@ func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
}
}
// TryAcquire attempts to acquire a slot without blocking
// Returns true if successful, false if rate limit would be exceeded
func (r *RateLimiter) TryAcquire() bool {
r.mu.Lock()
defer r.mu.Unlock()
@@ -87,7 +81,6 @@ func (r *RateLimiter) TryAcquire() bool {
return false
}
// Available returns the number of requests available in the current window
func (r *RateLimiter) Available() int {
r.mu.Lock()
defer r.mu.Unlock()
@@ -99,7 +92,6 @@ func (r *RateLimiter) Available() int {
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
// GetSongLinkRateLimiter returns the global SongLink rate limiter
func GetSongLinkRateLimiter() *RateLimiter {
return songLinkRateLimiter
}
-11
View File
@@ -5,7 +5,6 @@ import (
"unicode"
)
// Hiragana to Romaji mapping
var hiraganaToRomaji = map[rune]string{
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
@@ -30,7 +29,6 @@ var hiraganaToRomaji = map[rune]string{
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
}
// Katakana to Romaji mapping
var katakanaToRomaji = map[rune]string{
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
@@ -58,7 +56,6 @@ var katakanaToRomaji = map[rune]string{
'ヴ': "vu",
}
// Combination mappings for きゃ, しゃ, etc.
var combinationHiragana = map[string]string{
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
@@ -91,7 +88,6 @@ var combinationKatakana = map[string]string{
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
}
// ContainsJapanese checks if a string contains Japanese characters
func ContainsJapanese(s string) bool {
for _, r := range s {
if isHiragana(r) || isKatakana(r) || isKanji(r) {
@@ -114,8 +110,6 @@ func isKanji(r rune) bool {
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
}
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
func JapaneseToRomaji(text string) string {
if !ContainsJapanese(text) {
return text
@@ -175,8 +169,6 @@ func JapaneseToRomaji(text string) string {
return result.String()
}
// BuildSearchQuery creates a search query from track name and artist
// Converts Japanese to romaji if present
func BuildSearchQuery(trackName, artistName string) string {
// Convert Japanese to romaji
trackRomaji := JapaneseToRomaji(trackName)
@@ -189,7 +181,6 @@ func BuildSearchQuery(trackName, artistName string) string {
return strings.TrimSpace(artistClean + " " + trackClean)
}
// cleanSearchQuery removes special characters that might interfere with search
func cleanSearchQuery(s string) string {
var result strings.Builder
for _, r := range s {
@@ -202,8 +193,6 @@ func cleanSearchQuery(s string) string {
return strings.TrimSpace(result.String())
}
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
// This is useful for creating search queries that work better with Tidal's search
func CleanToASCII(s string) string {
var result strings.Builder
for _, r := range s {
+54 -32
View File
@@ -11,12 +11,10 @@ import (
"time"
)
// SongLinkClient handles song.link API interactions
type SongLinkClient struct {
client *http.Client
}
// TrackAvailability represents track availability on different platforms
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
@@ -35,7 +33,6 @@ var (
songLinkClientOnce sync.Once
)
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{
@@ -45,12 +42,34 @@ func NewSongLinkClient() *SongLinkClient {
return globalSongLinkClient
}
// CheckTrackAvailability checks track availability on streaming platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
if spotifyTrackID == "" {
return nil, fmt.Errorf("spotify track ID is empty")
}
// Try SongLink first
availability, err := s.checkTrackAvailabilitySongLink(spotifyTrackID)
if err != nil {
// Fallback to IDHS if SongLink fails
LogWarn("SongLink", "SongLink failed, trying IDHS fallback: %v", err)
idhsClient := NewIDHSClient()
availability, err = idhsClient.GetAvailabilityFromSpotify(spotifyTrackID)
if err != nil {
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
}
LogInfo("SongLink", "IDHS fallback successful for %s", spotifyTrackID)
}
// Check Qobuz availability separately via ISRC
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
return availability, nil
}
// checkTrackAvailabilitySongLink is the original SongLink implementation
func (s *SongLinkClient) checkTrackAvailabilitySongLink(spotifyTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
@@ -119,14 +138,9 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
return availability, nil
}
// GetStreamingURLs gets streaming URLs for a Spotify track
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
@@ -191,17 +205,16 @@ func extractDeezerIDFromURL(deezerURL string) string {
return ""
}
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
return "", err
}
if !availability.Deezer || availability.DeezerID == "" {
return "", fmt.Errorf("track not found on Deezer")
}
return availability.DeezerID, nil
}
@@ -213,7 +226,6 @@ type AlbumAvailability struct {
DeezerID string `json:"deezer_id,omitempty"`
}
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
// Use global rate limiter
songLinkRateLimiter.WaitForSlot()
@@ -275,25 +287,38 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
if err != nil {
return "", err
}
if !availability.Deezer || availability.DeezerID == "" {
return "", fmt.Errorf("album not found on Deezer")
}
return availability.DeezerID, nil
}
// ========================================
// Deezer ID Support - Query SongLink using Deezer as source
// ========================================
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
// This is useful when we have Deezer metadata and want to find the track on other platforms
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
if deezerTrackID == "" {
return nil, fmt.Errorf("deezer track ID is empty")
}
// Try SongLink first
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
if err != nil {
// Fallback to IDHS if SongLink fails
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()
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
@@ -374,7 +399,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return availability, nil
}
// CheckAvailabilityByPlatform checks track availability using any supported platform
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
// entityType: "song" or "album"
// entityID: the ID on that platform
@@ -382,7 +406,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
if entityID == "" {
return nil, fmt.Errorf("%s ID is empty", platform)
}
// Use global rate limiter
songLinkRateLimiter.WaitForSlot()
@@ -472,17 +496,16 @@ func extractSpotifyIDFromURL(spotifyURL string) string {
return ""
}
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil {
return "", err
}
if availability.SpotifyID == "" {
return "", fmt.Errorf("track not found on Spotify")
}
return availability.SpotifyID, nil
}
@@ -492,24 +515,23 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
if err != nil {
return "", err
}
if !availability.Tidal || availability.TidalURL == "" {
return "", fmt.Errorf("track not found on Tidal")
}
return availability.TidalURL, nil
}
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil {
return "", err
}
if !availability.Amazon || availability.AmazonURL == "" {
return "", fmt.Errorf("track not found on Amazon Music")
}
return availability.AmazonURL, nil
}
+42 -61
View File
@@ -24,7 +24,6 @@ const (
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
searchBaseURL = "https://api.spotify.com/v1/search"
// Cache TTL settings
artistCacheTTL = 10 * time.Minute
searchCacheTTL = 5 * time.Minute
albumCacheTTL = 10 * time.Minute
@@ -32,7 +31,6 @@ const (
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
// cacheEntry holds cached data with expiration
type cacheEntry struct {
data interface{}
expiresAt time.Time
@@ -42,26 +40,23 @@ func (e *cacheEntry) isExpired() bool {
return time.Now().After(e.expiresAt)
}
// SpotifyMetadataClient handles Spotify API interactions
type SpotifyMetadataClient struct {
httpClient *http.Client
clientID string
clientSecret string
cachedToken string
tokenExpiresAt time.Time
tokenMu sync.Mutex // Protects token cache for concurrent access
tokenMu sync.Mutex
rng *rand.Rand
rngMu sync.Mutex
userAgent string
// Caches to reduce API calls
artistCache map[string]*cacheEntry // key: artistID
searchCache map[string]*cacheEntry // key: query+type
albumCache map[string]*cacheEntry // key: albumID
artistCache map[string]*cacheEntry
searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry
cacheMu sync.RWMutex
}
// Custom credentials storage (set from Flutter)
var (
customClientID string
customClientSecret string
@@ -79,7 +74,6 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
customClientSecret = clientSecret
}
// HasSpotifyCredentials checks if Spotify credentials are configured
func HasSpotifyCredentials() bool {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
@@ -114,8 +108,6 @@ func getCredentials() (string, string, error) {
return "", "", ErrNoSpotifyCredentials
}
// NewSpotifyMetadataClient creates a new Spotify client
// Returns error if credentials are not configured
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
clientID, clientSecret, err := getCredentials()
if err != nil {
@@ -137,7 +129,6 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
return c, nil
}
// TrackMetadata represents track information
type TrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"`
@@ -155,7 +146,6 @@ type TrackMetadata struct {
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
}
// AlbumTrackMetadata holds per-track info for album/playlist
type AlbumTrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"`
@@ -172,25 +162,26 @@ type AlbumTrackMetadata struct {
ISRC string `json:"isrc"`
AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"`
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
AlbumType string `json:"album_type,omitempty"`
}
// AlbumInfoMetadata holds album information
type AlbumInfoMetadata struct {
TotalTracks int `json:"total_tracks"`
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
Artists string `json:"artists"`
ArtistId string `json:"artist_id,omitempty"`
Images string `json:"images"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
}
// AlbumResponsePayload is the response for album requests
type AlbumResponsePayload struct {
AlbumInfo AlbumInfoMetadata `json:"album_info"`
TrackList []AlbumTrackMetadata `json:"track_list"`
}
// PlaylistInfoMetadata holds playlist information
type PlaylistInfoMetadata struct {
Tracks struct {
Total int `json:"total"`
@@ -202,13 +193,11 @@ type PlaylistInfoMetadata struct {
} `json:"owner"`
}
// PlaylistResponsePayload is the response for playlist requests
type PlaylistResponsePayload struct {
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
TrackList []AlbumTrackMetadata `json:"track_list"`
}
// ArtistInfoMetadata holds artist information
type ArtistInfoMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -217,7 +206,6 @@ type ArtistInfoMetadata struct {
Popularity int `json:"popularity"`
}
// ArtistAlbumMetadata holds album info for artist discography
type ArtistAlbumMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -228,24 +216,20 @@ type ArtistAlbumMetadata struct {
Artists string `json:"artists"`
}
// ArtistResponsePayload is the response for artist requests
type ArtistResponsePayload struct {
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
Albums []ArtistAlbumMetadata `json:"albums"`
}
// TrackResponse is the response for single track requests
type TrackResponse struct {
Track TrackMetadata `json:"track"`
}
// SearchResult represents search results
type SearchResult struct {
Tracks []TrackMetadata `json:"tracks"`
Total int `json:"total"`
}
// SearchArtistResult represents an artist in search results
type SearchArtistResult struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -254,10 +238,29 @@ type SearchArtistResult struct {
Popularity int `json:"popularity"`
}
// SearchAllResult represents combined search results for tracks and artists
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 {
Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"`
Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"`
Albums []SearchAlbumResult `json:"albums"`
Playlists []SearchPlaylistResult `json:"playlists"`
}
type spotifyURI struct {
@@ -271,7 +274,6 @@ type accessTokenResponse struct {
TokenType string `json:"token_type"`
}
// Internal API response types
type image struct {
URL string `json:"url"`
}
@@ -297,7 +299,7 @@ type albumSimplified struct {
Images []image `json:"images"`
ExternalURL externalURL `json:"external_urls"`
Artists []artist `json:"artists"`
AlbumType string `json:"album_type"` // album, single, compilation
AlbumType string `json:"album_type"`
}
type trackFull struct {
@@ -312,7 +314,6 @@ type trackFull struct {
Artists []artist `json:"artists"`
}
// GetFilteredData fetches and formats Spotify data
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
parsed, err := parseSpotifyURI(spotifyURL)
if err != nil {
@@ -338,7 +339,6 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
}
}
// SearchTracks searches for tracks on Spotify
func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
token, err := c.getAccessToken(ctx)
if err != nil {
@@ -385,7 +385,6 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
return result, nil
}
// SearchAll searches for tracks and artists on Spotify
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
@@ -507,7 +506,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
}
c.cacheMu.RUnlock()
// Track item structure for pagination
type trackItem struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -535,19 +533,25 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
}
albumImage := firstImageURL(data.Images)
// Get first artist ID
var firstArtistId string
if len(data.Artists) > 0 {
firstArtistId = data.Artists[0].ID
}
info := AlbumInfoMetadata{
TotalTracks: data.TotalTracks,
Name: data.Name,
ReleaseDate: data.ReleaseDate,
Artists: joinArtists(data.Artists),
ArtistId: firstArtistId,
Images: albumImage,
}
// Collect all tracks (including paginated)
allTrackItems := data.Tracks.Items
nextURL := data.Tracks.Next
// Fetch remaining tracks using pagination (no limit)
for nextURL != "" {
var pageData struct {
Items []trackItem `json:"items"`
@@ -569,7 +573,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
trackIDs[i] = item.ID
}
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
@@ -609,10 +612,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
return result, nil
}
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
// Similar to Deezer implementation for consistency
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
const maxParallelISRC = 10 // Max concurrent ISRC fetches
const maxParallelISRC = 10
result := make(map[string]string)
var resultMu sync.Mutex
@@ -621,7 +622,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
return result
}
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, maxParallelISRC)
var wg sync.WaitGroup
@@ -630,7 +630,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
go func(id string) {
defer wg.Done()
// Acquire semaphore
select {
case sem <- struct{}{}:
defer func() { <-sem }()
@@ -651,7 +650,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
}
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
// First request to get playlist info and first batch of tracks
var data struct {
Name string `json:"name"`
Images []image `json:"images"`
@@ -677,10 +675,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
info.Owner.Name = data.Name
info.Owner.Images = firstImageURL(data.Images)
// Pre-allocate with expected capacity
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
// Add first batch of tracks
for _, item := range data.Tracks.Items {
if item.Track == nil {
continue
@@ -704,7 +700,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
})
}
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
nextURL := data.Tracks.Next
for nextURL != "" {
@@ -716,7 +711,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
}
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
// Log error but return what we have so far
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
break
}
@@ -763,7 +757,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
}
c.cacheMu.RUnlock()
// Fetch artist info
var artistData struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -786,7 +779,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
Popularity: artistData.Popularity,
}
// Fetch artist albums (all types: album, single, compilation)
albums := make([]ArtistAlbumMetadata, 0)
offset := 0
limit := 50
@@ -826,13 +818,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
})
}
// Check if there are more albums
if albumsData.Next == "" || len(albumsData.Items) < limit {
break
}
offset += limit
// Safety limit to prevent infinite loops
if offset > 500 {
break
}
@@ -913,7 +903,6 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
return err
}
// Set headers (same as PC version baseHeaders)
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
@@ -949,8 +938,7 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
c.rngMu.Lock()
defer c.rngMu.Unlock()
// Use Mac User-Agent format (same as PC version)
macMajor := c.rng.Intn(4) + 11 // 11-14
macMajor := c.rng.Intn(4) + 11
macMinor := c.rng.Intn(5) + 4 // 4-8
webkitMajor := c.rng.Intn(7) + 530 // 530-536
webkitMinor := c.rng.Intn(7) + 30 // 30-36
@@ -975,7 +963,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL
}
// Handle spotify: URI format
if strings.HasPrefix(trimmed, "spotify:") {
parts := strings.Split(trimmed, ":")
if len(parts) == 3 {
@@ -986,13 +973,11 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
}
}
// Handle URL format
parsed, err := url.Parse(trimmed)
if err != nil {
return spotifyURI{}, err
}
// Handle embed.spotify.com URLs
if parsed.Host == "embed.spotify.com" {
if parsed.RawQuery == "" {
return spotifyURI{}, errInvalidSpotifyURL
@@ -1005,7 +990,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return parseSpotifyURI(embedded)
}
// Handle plain ID (no scheme/host) - defaults to playlist
if parsed.Scheme == "" && parsed.Host == "" {
id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
if id == "" {
@@ -1031,7 +1015,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL
}
// Skip intl- prefix if present
if strings.HasPrefix(parts[0], "intl-") {
parts = parts[1:]
}
@@ -1039,7 +1022,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL
}
// Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id}
if len(parts) == 2 {
switch parts[0] {
case "album", "track", "playlist", "artist":
@@ -1047,7 +1029,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
}
}
// Handle nested playlist URLs: /user/{user}/playlist/{id}
if len(parts) == 4 && parts[2] == "playlist" {
return spotifyURI{Type: "playlist", ID: parts[3]}, nil
}
+73 -178
View File
@@ -19,7 +19,6 @@ import (
"time"
)
// TidalDownloader handles Tidal downloads
type TidalDownloader struct {
client *http.Client
clientID string
@@ -35,7 +34,6 @@ var (
tidalDownloaderOnce sync.Once
)
// TidalTrack represents a Tidal track
type TidalTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -60,7 +58,6 @@ type TidalTrack struct {
} `json:"mediaMetadata"`
}
// TidalAPIResponseV2 is the new API response format (version 2.0)
type TidalAPIResponseV2 struct {
Version string `json:"version"`
Data struct {
@@ -76,7 +73,6 @@ type TidalAPIResponseV2 struct {
} `json:"data"`
}
// TidalBTSManifest is the BTS (application/vnd.tidal.bts) manifest format
type TidalBTSManifest struct {
MimeType string `json:"mimeType"`
Codecs string `json:"codecs"`
@@ -84,7 +80,6 @@ type TidalBTSManifest struct {
URLs []string `json:"urls"`
}
// MPD represents DASH manifest structure
type MPD struct {
XMLName xml.Name `xml:"MPD"`
Period struct {
@@ -105,7 +100,6 @@ type MPD struct {
} `xml:"Period"`
}
// NewTidalDownloader creates a new Tidal downloader (returns singleton for token reuse)
func NewTidalDownloader() *TidalDownloader {
tidalDownloaderOnce.Do(func() {
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
@@ -128,14 +122,16 @@ func NewTidalDownloader() *TidalDownloader {
// GetAvailableAPIs returns list of available Tidal APIs
func (t *TidalDownloader) GetAvailableAPIs() []string {
encodedAPIs := []string{
"dGlkYWwua2lub3BsdXMub25saW5l",
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn",
"dHJpdG9uLnNxdWlkLnd0Zg==",
"dm9nZWwucXFkbC5zaXRl",
"bWF1cy5xcWRsLnNpdGU=",
"aHVuZC5xcWRsLnNpdGU=",
"a2F0emUucXFkbC5zaXRl",
"d29sZi5xcWRsLnNpdGU=",
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
}
var apis []string
@@ -150,7 +146,6 @@ func (t *TidalDownloader) GetAvailableAPIs() []string {
return apis
}
// GetAccessToken gets Tidal access token (with caching)
func (t *TidalDownloader) GetAccessToken() (string, error) {
t.tokenMu.Lock()
defer t.tokenMu.Unlock()
@@ -199,7 +194,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
return result.AccessToken, nil
}
// GetTidalURLFromSpotify gets Tidal URL from Spotify track ID using SongLink
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -239,7 +233,6 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string,
return tidalLink.URL, nil
}
// GetTrackIDFromURL extracts track ID from Tidal URL
func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
parts := strings.Split(tidalURL, "/track/")
if len(parts) < 2 {
@@ -293,7 +286,6 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
return &trackInfo, nil
}
// SearchTrackByISRC searches for a track by ISRC
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
token, err := t.GetAccessToken()
if err != nil {
@@ -341,30 +333,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// normalizeTitle normalizes a track title for comparison
// Kept for potential future use
// func normalizeTitle(title string) string {
// normalized := strings.ToLower(strings.TrimSpace(title))
//
// // Remove common suffixes in parentheses or brackets
// suffixPatterns := []string{
// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
// " (bonus track)", " (single)", " (album version)", " (radio edit)",
// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
// }
// for _, suffix := range suffixPatterns {
// normalized = strings.TrimSuffix(normalized, suffix)
// }
//
// // Remove multiple spaces
// for strings.Contains(normalized, " ") {
// normalized = strings.ReplaceAll(normalized, " ", " ")
// }
//
// return normalized
// }
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
token, err := t.GetAccessToken()
@@ -466,7 +434,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
if len(result.Items) > 0 {
GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
// OPTIMIZATION: If ISRC provided, check for match immediately and return early
if spotifyISRC != "" {
for i := range result.Items {
if result.Items[i].ISRC == spotifyISRC {
@@ -477,13 +444,13 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
durationDiff = -durationDiff
}
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
}
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
expectedDuration, track.Duration)
} else {
GoLog("[Tidal] ISRC match: '%s'\n", track.Title)
GoLog("[Tidal] ISRC match: '%s'\n", track.Title)
return track, nil
}
}
@@ -522,7 +489,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
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)
return durationVerifiedMatches[0], nil
}
@@ -533,11 +500,11 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
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
}
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)
}
@@ -592,7 +559,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
return bestMatch, nil
}
// containsQuery checks if a query already exists in the list
func containsQuery(queries []string, query string) bool {
for _, q := range queries {
if q == query {
@@ -602,7 +568,6 @@ func containsQuery(queries []string, query string) bool {
return false
}
// SearchTrackByMetadata searches for a track using artist name and track name
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
}
@@ -614,7 +579,6 @@ type TidalDownloadInfo struct {
SampleRate int
}
// tidalAPIResult holds the result from a parallel API request
type tidalAPIResult struct {
apiURL string
info TidalDownloadInfo
@@ -622,9 +586,7 @@ type tidalAPIResult struct {
duration time.Duration
}
// getDownloadURLParallel requests download URL from all APIs in parallel
// Returns the first successful result (supports both v1 and v2 API formats)
// "Siapa cepat dia dapat" - first success wins
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
if len(apis) == 0 {
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
@@ -639,9 +601,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
go func(api string) {
reqStart := time.Now()
client := &http.Client{
Timeout: 15 * time.Second,
}
client := NewHTTPClientWithTimeout(15 * time.Second)
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
@@ -671,7 +631,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
if v2Response.Data.AssetPresentation == "PREVIEW" {
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
return
@@ -712,10 +671,9 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
for i := 0; i < len(apis); i++ {
result := <-resultChan
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)
// Don't return immediately - drain remaining results to avoid goroutine leaks
go func(remaining int) {
for j := 0; j < remaining; j++ {
<-resultChan
@@ -736,8 +694,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
}
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
apis := t.GetAvailableAPIs()
if len(apis) == 0 {
@@ -752,7 +708,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDo
return info, nil
}
// parseManifest parses Tidal manifest (supports both BTS and DASH formats)
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil {
@@ -859,7 +814,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
}
// Initialize item progress for direct downloads
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
@@ -946,14 +900,10 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n",
directURL != "", initURL != "", len(mediaURLs))
client := &http.Client{
Timeout: 120 * time.Second,
}
client := NewHTTPClientWithTimeout(120 * time.Second)
if directURL != "" {
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
// Note: Progress tracking is initialized by the caller (DownloadFile)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
@@ -1135,7 +1085,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return nil
}
// TidalDownloadResult contains download result with quality info
type TidalDownloadResult struct {
FilePath string
BitDepth int
@@ -1149,12 +1098,10 @@ type TidalDownloadResult struct {
ISRC string
}
// artistsMatch checks if the artist names are similar enough
func artistsMatch(spotifyArtist, tidalArtist string) bool {
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
// Exact match
if normSpotify == normTidal {
return true
}
@@ -1164,22 +1111,17 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return true
}
// Split artists by common separators (comma, feat, ft., &, and)
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
spotifyArtists := splitArtists(normSpotify)
tidalArtists := splitArtists(normTidal)
// Check if ANY expected artist matches ANY found artist
for _, exp := range spotifyArtists {
for _, fnd := range tidalArtists {
if exp == fnd {
return true
}
// Also check contains for partial matches
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true
}
// Check same words different order
if sameWordsUnordered(exp, fnd) {
GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true
@@ -1187,9 +1129,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
}
}
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
spotifyLatin := isLatinScript(spotifyArtist)
tidalLatin := isLatinScript(tidalArtist)
if spotifyLatin != tidalLatin {
@@ -1200,9 +1139,7 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return false
}
// splitArtists splits artist string by common separators
func splitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|")
@@ -1224,8 +1161,6 @@ func splitArtists(artists string) []string {
return result
}
// sameWordsUnordered checks if two strings have the same words regardless of order
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
func sameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a)
wordsB := strings.Fields(b)
@@ -1235,13 +1170,11 @@ func sameWordsUnordered(a, b string) bool {
return false
}
// Sort and compare
sortedA := make([]string, len(wordsA))
sortedB := make([]string, len(wordsB))
copy(sortedA, wordsA)
copy(sortedB, wordsB)
// Simple bubble sort (usually just 2-3 words)
for i := 0; i < len(sortedA)-1; i++ {
for j := i + 1; j < len(sortedA); j++ {
if sortedA[i] > sortedA[j] {
@@ -1261,7 +1194,6 @@ func sameWordsUnordered(a, b string) bool {
return true
}
// titlesMatch checks if track titles are similar enough
func titlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
@@ -1271,7 +1203,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
@@ -1284,7 +1215,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true
}
// Check if cleaned versions contain each other
if cleanExpected != "" && cleanFound != "" {
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
return true
@@ -1299,7 +1229,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
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 := isLatinScript(expectedTitle)
foundLatin := isLatinScript(foundTitle)
@@ -1311,9 +1240,7 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return false
}
// extractCoreTitle extracts the main title before any parentheses or brackets
func extractCoreTitle(title string) string {
// Find first occurrence of ( or [
parenIdx := strings.Index(title, "(")
bracketIdx := strings.Index(title, "[")
dashIdx := strings.Index(title, " - ")
@@ -1332,18 +1259,15 @@ func extractCoreTitle(title string) string {
return strings.TrimSpace(title[:cutIdx])
}
// cleanTitle removes common suffixes from track titles for comparison
func cleanTitle(title string) string {
cleaned := title
// Version indicators to remove from parentheses/brackets
versionPatterns := []string{
"remaster", "remastered", "deluxe", "bonus", "single",
"album version", "radio edit", "original mix", "extended",
"club mix", "remix", "live", "acoustic", "demo",
}
// Remove parenthetical content if it contains version indicators
for {
startParen := strings.LastIndex(cleaned, "(")
endParen := strings.LastIndex(cleaned, ")")
@@ -1364,7 +1288,6 @@ func cleanTitle(title string) string {
break
}
// Same for brackets
for {
startBracket := strings.LastIndex(cleaned, "[")
endBracket := strings.LastIndex(cleaned, "]")
@@ -1385,7 +1308,6 @@ func cleanTitle(title string) string {
break
}
// Remove trailing " - version" patterns
dashPatterns := []string{
" - remaster", " - remastered", " - single version", " - radio edit",
" - live", " - acoustic", " - demo", " - remix",
@@ -1396,7 +1318,6 @@ func cleanTitle(title string) string {
}
}
// Remove multiple spaces
for strings.Contains(cleaned, " ") {
cleaned = strings.ReplaceAll(cleaned, " ", " ")
}
@@ -1404,48 +1325,28 @@ func cleanTitle(title string) string {
return strings.TrimSpace(cleaned)
}
// isLatinScript 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 isLatinScript(s string) bool {
for _, r := range s {
// Skip common punctuation and numbers
if r < 128 {
continue
}
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
// Latin Extended-B: U+0180 to U+024F
// Latin Extended Additional: U+1E00 to U+1EFF
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)
if (r >= 0x0100 && r <= 0x024F) ||
(r >= 0x1E00 && r <= 0x1EFF) ||
(r >= 0x00C0 && r <= 0x00FF) {
continue
}
// CJK ranges - definitely different script
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
(r >= 0x3040 && r <= 0x309F) || // Hiragana
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
(r >= 0x0600 && r <= 0x06FF) || // Arabic
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
if (r >= 0x4E00 && r <= 0x9FFF) ||
(r >= 0x3040 && r <= 0x309F) ||
(r >= 0x30A0 && r <= 0x30FF) ||
(r >= 0xAC00 && r <= 0xD7AF) ||
(r >= 0x0600 && r <= 0x06FF) ||
(r >= 0x0400 && r <= 0x04FF) {
return false
}
}
return true
}
// isASCIIString checks if a string contains only ASCII characters
// Kept for potential future use
// func isASCIIString(s string) bool {
// for _, r := range s {
// if r > 127 {
// return false
// }
// }
// return true
// }
// downloadFromTidal downloads a track using the request parameters
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader()
@@ -1453,16 +1354,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000
var track *TidalTrack
var err error
// STRATEGY 0: Use pre-fetched Tidal ID from Odesli enrichment (highest priority)
if req.TidalID != "" {
GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID)
// Parse track ID (could be a number or extracted from URL)
var trackID int64
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
track, err = downloader.GetTrackInfoByID(trackID)
@@ -1475,7 +1373,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
}
// OPTIMIZATION: Check cache first for track ID
if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
@@ -1487,8 +1384,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
}
// OPTIMIZED: Try ISRC search with metadata (search by name, filter by ISRC)
// Strategy 1: Search by metadata, match by ISRC (most accurate)
if track == nil && req.ISRC != "" {
GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
@@ -1510,7 +1405,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
}
// Strategy 2: Try SongLink if we have Spotify ID
if track == nil && req.SpotifyID != "" {
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
var tidalURL string
@@ -1545,13 +1439,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
track = nil
}
// Verify duration if we have expected duration
if track != nil && expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 3 seconds tolerance (same as PC version)
if durationDiff > 3 {
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration)
@@ -1563,11 +1455,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
}
// Strategy 3: Search by metadata only (no ISRC requirement) - last resort
if track == nil {
GoLog("[Tidal] Trying metadata search as last resort...\n")
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
// Verify artist AND title for metadata search
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
@@ -1578,7 +1468,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
tidalArtist = strings.Join(artistNames, ", ")
}
// Verify title first
if !titlesMatch(req.TrackName, track.Title) {
GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.TrackName, track.Title)
@@ -1599,7 +1488,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
}
// Final verification logging
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
@@ -1633,7 +1521,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
}
// Clean up any leftover .tmp files from previous failed downloads
tmpPath := outputPath + ".m4a.tmp"
if _, err := os.Stat(tmpPath); err == nil {
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
@@ -1651,10 +1538,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
// Log actual quality received
GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
@@ -1670,7 +1555,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
)
}()
// Download audio file with item ID for progress tracking
GoLog("[Tidal] Starting download to: %s\n", outputPath)
GoLog("[Tidal] Download URL type: %s\n", func() string {
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
@@ -1688,7 +1572,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
fmt.Println("[Tidal] Download completed successfully")
// Wait for parallel operations to complete
<-parallelDone
if req.ItemID != "" {
@@ -1701,21 +1584,38 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
actualOutputPath = m4aPath
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
} else if _, err := os.Stat(outputPath); err != nil {
// Neither FLAC nor M4A exists
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
}
// Embed metadata using parallel-fetched cover data
releaseDate := req.ReleaseDate
if releaseDate == "" && track.Album.ReleaseDate != "" {
releaseDate = track.Album.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
actualDiscNumber := req.DiscNumber
if actualTrackNumber == 0 {
actualTrackNumber = track.TrackNumber
}
if actualDiscNumber == 0 {
actualDiscNumber = track.VolumeNumber
}
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate,
TrackNumber: track.TrackNumber, // Use actual track number from Tidal
Date: releaseDate,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal
ISRC: track.ISRC, // Use actual ISRC from Tidal
DiscNumber: actualDiscNumber,
ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
@@ -1724,46 +1624,41 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
}
// Embed metadata based on file type
if strings.HasSuffix(actualOutputPath, ".flac") {
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Tidal] Lyrics embedded successfully")
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Tidal] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Tidal] Lyrics embedded successfully")
}
}
} else if req.EmbedLyrics {
fmt.Println("[Tidal] No lyrics available from parallel fetch")
}
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
// Embed metadata to M4A file
// GoLog("[Tidal] Embedding metadata to M4A file...\n")
// Add lyrics to metadata if available
// if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
// metadata.Lyrics = parallelResult.LyricsLRC
// }
// SKIP metadata embedding for M4A to prevent issues with FFmpeg conversion
// M4A files from DASH are often fragmented and editing metadata might corrupt the container
// structure that FFmpeg expects. Metadata will be re-embedded after conversion to FLAC in Flutter.
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
// if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil {
// GoLog("[Tidal] Warning: failed to embed M4A metadata: %v\n", err)
// } else {
// fmt.Println("[Tidal] M4A metadata embedded successfully")
// }
}
// Add to ISRC index for fast duplicate checking
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
return TidalDownloadResult{
@@ -1774,8 +1669,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
Artist: track.Artist.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber,
DiscNumber: track.VolumeNumber,
TrackNumber: actualTrackNumber,
DiscNumber: actualDiscNumber,
ISRC: track.ISRC,
}, nil
}
+98 -1
View File
@@ -142,6 +142,27 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "checkDuplicatesBatch":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
let tracksJson = args["tracks"] as? String ?? "[]"
let response = GobackendCheckDuplicatesBatch(outputDir, tracksJson, &error)
if let error = error { throw error }
return response
case "preBuildDuplicateIndex":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
GobackendPreBuildDuplicateIndex(outputDir, &error)
if let error = error { throw error }
return nil
case "invalidateDuplicateIndex":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
GobackendInvalidateDuplicateIndex(outputDir)
return nil
case "buildFilename":
let args = call.arguments as! [String: Any]
let template = args["template"] as! String
@@ -201,7 +222,8 @@ import Gobackend // Import Go framework
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
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 }
return response
@@ -227,6 +249,13 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getDeezerExtendedMetadata":
let args = call.arguments as! [String: Any]
let trackId = args["track_id"] as! String
let response = GobackendGetDeezerExtendedMetadata(trackId, &error)
if let error = error { throw error }
return response
case "convertSpotifyToDeezer":
let args = call.arguments as! [String: Any]
let resourceType = args["resource_type"] as! String
@@ -242,6 +271,43 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "checkAvailabilityFromDeezerID":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendCheckAvailabilityFromDeezerID(deezerTrackId, &error)
if let error = error { throw error }
return response
case "checkAvailabilityByPlatformID":
let args = call.arguments as! [String: Any]
let platform = args["platform"] as! String
let entityType = args["entity_type"] as! String
let entityId = args["entity_id"] as! String
let response = GobackendCheckAvailabilityByPlatformID(platform, entityType, entityId, &error)
if let error = error { throw error }
return response
case "getSpotifyIDFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetSpotifyIDFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "getTidalURLFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetTidalURLFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "getAmazonURLFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "preWarmTrackCache":
let args = call.arguments as! [String: Any]
let tracksJson = args["tracks"] as! String
@@ -375,6 +441,14 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return nil
case "invokeExtensionAction":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let actionName = args["action"] as! String
let response = GobackendInvokeExtensionActionJSON(extensionId, actionName, &error)
if let error = error { throw error }
return response
case "searchTracksWithExtensions":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
@@ -389,6 +463,14 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "enrichTrackWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let trackJson = args["track"] as? String ?? "{}"
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
if let error = error { throw error }
return response
case "removeExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -590,6 +672,21 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return nil
// Extension Home Feed API
case "getExtensionHomeFeed":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error)
if let error = error { throw error }
return response
case "getExtensionBrowseCategories":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendGetExtensionBrowseCategoriesJSON(extensionId, &error)
if let error = error { throw error }
return response
default:
throw NSError(
domain: "SpotiFLAC",
+6 -1
View File
@@ -36,7 +36,12 @@ class SpotiFLACApp extends ConsumerWidget {
Locale? locale;
if (localeString != 'system') {
locale = Locale(localeString);
if (localeString.contains('_')) {
final parts = localeString.split('_');
locale = Locale(parts[0], parts[1]);
} else {
locale = Locale(localeString);
}
}
return DynamicColorWrapper(
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.1.1';
static const String buildNumber = '60';
static const String version = '3.3.1';
static const String buildNumber = '68';
static const String fullVersion = '$version+$buildNumber';
+389
View File
@@ -16,6 +16,7 @@ import 'app_localizations_ko.dart';
import 'app_localizations_nl.dart';
import 'app_localizations_pt.dart';
import 'app_localizations_ru.dart';
import 'app_localizations_tr.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
@@ -107,6 +108,7 @@ abstract class AppLocalizations {
Locale('de'),
Locale('en'),
Locale('es'),
Locale('es', 'ES'),
Locale('fr'),
Locale('hi'),
Locale('id'),
@@ -114,7 +116,9 @@ abstract class AppLocalizations {
Locale('ko'),
Locale('nl'),
Locale('pt'),
Locale('pt', 'PT'),
Locale('ru'),
Locale('tr'),
Locale('zh'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
@@ -276,6 +280,12 @@ abstract class AppLocalizations {
/// **'Single track downloads will appear here'**
String get historyNoSinglesSubtitle;
/// Search bar placeholder in history
///
/// In en, this message translates to:
/// **'Search history...'**
String get historySearchHint;
/// Settings screen title
///
/// In en, this message translates to:
@@ -816,6 +826,12 @@ abstract class AppLocalizations {
/// **'The talented artist who created our beautiful app logo!'**
String get aboutLogoArtist;
/// Section for translators
///
/// In en, this message translates to:
/// **'Translators'**
String get aboutTranslators;
/// Section for special thanks
///
/// In en, this message translates to:
@@ -864,6 +880,36 @@ abstract class AppLocalizations {
/// **'Suggest new features for the app'**
String get aboutFeatureRequestSubtitle;
/// Link to Telegram channel
///
/// In en, this message translates to:
/// **'Telegram Channel'**
String get aboutTelegramChannel;
/// Subtitle for Telegram channel
///
/// In en, this message translates to:
/// **'Announcements and updates'**
String get aboutTelegramChannelSubtitle;
/// Link to Telegram chat group
///
/// In en, this message translates to:
/// **'Telegram Community'**
String get aboutTelegramChat;
/// Subtitle for Telegram chat
///
/// In en, this message translates to:
/// **'Chat with other users'**
String get aboutTelegramChatSubtitle;
/// Section for social links
///
/// In en, this message translates to:
/// **'Social'**
String get aboutSocial;
/// Section for support/donation links
///
/// In en, this message translates to:
@@ -906,6 +952,12 @@ abstract class AppLocalizations {
/// **'The original HiFi project creator. The foundation of Tidal integration!'**
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
///
/// In en, this message translates to:
@@ -1680,6 +1732,12 @@ abstract class AppLocalizations {
/// **'Found {count} tracks in CSV. Add them to download queue?'**
String dialogImportPlaylistMessage(int count);
/// Label shown in quality picker for CSV import
///
/// In en, this message translates to:
/// **'{count} tracks from CSV'**
String csvImportTracks(int count);
/// Snackbar - track added to download queue
///
/// In en, this message translates to:
@@ -2604,6 +2662,60 @@ abstract class AppLocalizations {
/// **'File Settings'**
String get sectionFileSettings;
/// Settings section header
///
/// In en, this message translates to:
/// **'Lyrics'**
String get sectionLyrics;
/// Setting - how to save lyrics
///
/// In en, this message translates to:
/// **'Lyrics Mode'**
String get lyricsMode;
/// Lyrics mode picker description
///
/// In en, this message translates to:
/// **'Choose how lyrics are saved with your downloads'**
String get lyricsModeDescription;
/// Lyrics mode option - embed in audio file
///
/// In en, this message translates to:
/// **'Embed in file'**
String get lyricsModeEmbed;
/// Subtitle for embed option
///
/// In en, this message translates to:
/// **'Lyrics stored inside FLAC metadata'**
String get lyricsModeEmbedSubtitle;
/// Lyrics mode option - separate LRC file
///
/// In en, this message translates to:
/// **'External .lrc file'**
String get lyricsModeExternal;
/// Subtitle for external option
///
/// In en, this message translates to:
/// **'Separate .lrc file for players like Samsung Music'**
String get lyricsModeExternalSubtitle;
/// Lyrics mode option - embed and external
///
/// In en, this message translates to:
/// **'Both'**
String get lyricsModeBoth;
/// Subtitle for both option
///
/// In en, this message translates to:
/// **'Embed and save .lrc file'**
String get lyricsModeBothSubtitle;
/// Settings section header
///
/// In en, this message translates to:
@@ -2808,6 +2920,24 @@ abstract class AppLocalizations {
/// **'Release date'**
String get trackReleaseDate;
/// Metadata label - music genre
///
/// In en, this message translates to:
/// **'Genre'**
String get trackGenre;
/// Metadata label - record label
///
/// In en, this message translates to:
/// **'Label'**
String get trackLabel;
/// Metadata label - copyright information
///
/// In en, this message translates to:
/// **'Copyright'**
String get trackCopyright;
/// Metadata label - download date
///
/// In en, this message translates to:
@@ -2838,6 +2968,24 @@ abstract class AppLocalizations {
/// **'Failed to load lyrics'**
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
///
/// In en, this message translates to:
@@ -3252,6 +3400,66 @@ abstract class AppLocalizations {
/// **'24-bit / up to 192kHz'**
String get qualityHiResFlacMaxSubtitle;
/// Quality option - lossy format (MP3/Opus)
///
/// In en, this message translates to:
/// **'Lossy'**
String get qualityLossy;
/// Technical spec for lossy MP3
///
/// In en, this message translates to:
/// **'MP3 320kbps (converted from FLAC)'**
String get qualityLossyMp3Subtitle;
/// Technical spec for lossy Opus
///
/// In en, this message translates to:
/// **'Opus 128kbps (converted from FLAC)'**
String get qualityLossyOpusSubtitle;
/// Setting - enable lossy quality option
///
/// In en, this message translates to:
/// **'Enable Lossy Option'**
String get enableLossyOption;
/// Subtitle when lossy is enabled
///
/// In en, this message translates to:
/// **'Lossy quality option is available'**
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
///
/// In en, this message translates to:
@@ -3534,6 +3742,18 @@ abstract class AppLocalizations {
/// **'Albums/[2005] Album Name/'**
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
///
/// In en, this message translates to:
@@ -3588,6 +3808,12 @@ abstract class AppLocalizations {
/// **'Select tracks to delete'**
String get downloadedAlbumSelectToDelete;
/// Header for disc separator in multi-disc albums
///
/// In en, this message translates to:
/// **'Disc {discNumber}'**
String downloadedAlbumDiscHeader(int discNumber);
/// Extension capability - utility functions
///
/// In en, this message translates to:
@@ -3629,6 +3855,150 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Error: {message}'**
String errorGeneric(String message);
/// Button - download artist discography
///
/// In en, this message translates to:
/// **'Download Discography'**
String get discographyDownload;
/// Option - download entire discography
///
/// In en, this message translates to:
/// **'Download All'**
String get discographyDownloadAll;
/// Subtitle showing total tracks and albums
///
/// In en, this message translates to:
/// **'{count} tracks from {albumCount} releases'**
String discographyDownloadAllSubtitle(int count, int albumCount);
/// Option - download only albums
///
/// In en, this message translates to:
/// **'Albums Only'**
String get discographyAlbumsOnly;
/// Subtitle showing album tracks count
///
/// In en, this message translates to:
/// **'{count} tracks from {albumCount} albums'**
String discographyAlbumsOnlySubtitle(int count, int albumCount);
/// Option - download only singles
///
/// In en, this message translates to:
/// **'Singles & EPs Only'**
String get discographySinglesOnly;
/// Subtitle showing singles tracks count
///
/// In en, this message translates to:
/// **'{count} tracks from {albumCount} singles'**
String discographySinglesOnlySubtitle(int count, int albumCount);
/// Option - manually select albums to download
///
/// In en, this message translates to:
/// **'Select Albums...'**
String get discographySelectAlbums;
/// Subtitle for select albums option
///
/// In en, this message translates to:
/// **'Choose specific albums or singles'**
String get discographySelectAlbumsSubtitle;
/// Progress - fetching album tracks
///
/// In en, this message translates to:
/// **'Fetching tracks...'**
String get discographyFetchingTracks;
/// Progress - fetching specific album
///
/// In en, this message translates to:
/// **'Fetching {current} of {total}...'**
String discographyFetchingAlbum(int current, int total);
/// Selection count badge
///
/// In en, this message translates to:
/// **'{count} selected'**
String discographySelectedCount(int count);
/// Button - download selected albums
///
/// In en, this message translates to:
/// **'Download Selected'**
String get discographyDownloadSelected;
/// Snackbar - tracks added from discography
///
/// In en, this message translates to:
/// **'Added {count} tracks to queue'**
String discographyAddedToQueue(int count);
/// Snackbar - with skipped tracks count
///
/// In en, this message translates to:
/// **'{added} added, {skipped} already downloaded'**
String discographySkippedDownloaded(int added, int skipped);
/// Error - no albums found for artist
///
/// In en, this message translates to:
/// **'No albums available'**
String get discographyNoAlbums;
/// Error - some albums failed to load
///
/// In en, this message translates to:
/// **'Failed to fetch some albums'**
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
@@ -3653,6 +4023,7 @@ class _AppLocalizationsDelegate
'nl',
'pt',
'ru',
'tr',
'zh',
].contains(locale.languageCode);
@@ -3663,6 +4034,22 @@ class _AppLocalizationsDelegate
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when language+country codes are specified.
switch (locale.languageCode) {
case 'es':
{
switch (locale.countryCode) {
case 'ES':
return AppLocalizationsEsEs();
}
break;
}
case 'pt':
{
switch (locale.countryCode) {
case 'PT':
return AppLocalizationsPtPt();
}
break;
}
case 'zh':
{
switch (locale.countryCode) {
@@ -3699,6 +4086,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsPt();
case 'ru':
return AppLocalizationsRu();
case 'tr':
return AppLocalizationsTr();
case 'zh':
return AppLocalizationsZh();
}
+302 -86
View File
@@ -111,11 +111,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Einzelne Titel-Downloads werden hier angezeigt';
@override
String get historySearchHint => 'Suchverlauf...';
@override
String get settingsTitle => 'Einstellungen';
@override
String get settingsDownload => 'Download';
String get settingsDownload => 'Herunterladen';
@override
String get settingsAppearance => 'Erscheinungsbild';
@@ -130,7 +133,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsAbout => 'Über';
@override
String get downloadTitle => 'Download';
String get downloadTitle => 'Herunterladen';
@override
String get downloadLocation => 'Download-Speicherort';
@@ -410,40 +413,61 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
'Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!';
@override
String get aboutSpecialThanks => 'Special Thanks';
String get aboutTranslators => 'Übersetzer';
@override
String get aboutSpecialThanks => 'Besonderer Dank';
@override
String get aboutLinks => 'Links';
@override
String get aboutMobileSource => 'Mobile source code';
String get aboutMobileSource => 'Mobiler Quellcode';
@override
String get aboutPCSource => 'PC source code';
String get aboutPCSource => 'PC Quellcode';
@override
String get aboutReportIssue => 'Report an issue';
String get aboutReportIssue => 'Problem melden';
@override
String get aboutReportIssueSubtitle => 'Report any problems you encounter';
String get aboutReportIssueSubtitle =>
'Melde jedes Problem, die dir auftreten';
@override
String get aboutFeatureRequest => 'Feature request';
String get aboutFeatureRequest => 'Feature vorschlagen';
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
String get aboutFeatureRequestSubtitle =>
'Schlage neue Funktionen für die App vor';
@override
String get aboutTelegramChannel => 'Telegram Kanal';
@override
String get aboutTelegramChannelSubtitle => 'Ankündigungen und Updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Mit anderen Nutzern chatten';
@override
String get aboutSocial => 'Sozial';
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
String get aboutBuyMeCoffee => 'Spendiere mir einen Kaffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
String get aboutBuyMeCoffeeSubtitle =>
'Unterstütze die Entwicklung auf Ko-fi';
@override
String get aboutApp => 'App';
@@ -453,29 +477,33 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
'Der Schöpfer der QQDL & HiFi API. Ohne diese API gäbe es keine Tidal-Downloads!';
@override
String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of 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
String get aboutDoubleDouble => 'DoubleDouble';
@override
String get aboutDoubleDoubleDesc =>
'Amazing API for Amazon Music downloads. Thank you for making it free!';
'Wundervolle API für Amazon Music Downloads.\nVielen Dank, dass Sie sie kostenlos zur Verfügung stellen!';
@override
String get aboutDabMusic => 'DAB Music';
@override
String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
'Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!';
@override
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
String get albumTitle => 'Album';
@@ -485,246 +513,248 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
other: '$count Songs',
one: '1 Song',
);
return '$_temp0';
}
@override
String get albumDownloadAll => 'Download All';
String get albumDownloadAll => 'Alle Herunterladen';
@override
String get albumDownloadRemaining => 'Download Remaining';
String get albumDownloadRemaining => 'Downloads verbleibend';
@override
String get playlistTitle => 'Playlist';
@override
String get artistTitle => 'Artist';
String get artistTitle => 'Künstler';
@override
String get artistAlbums => 'Albums';
String get artistAlbums => 'Alben';
@override
String get artistSingles => 'Singles & EPs';
@override
String get artistCompilations => 'Compilations';
String get artistCompilations => 'Zusammenstellungen';
@override
String artistReleases(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count releases',
one: '1 release',
other: '$count Veröffentlichungen',
one: '1 Veröffentlichung',
);
return '$_temp0';
}
@override
String get artistPopular => 'Popular';
String get artistPopular => 'Beliebt';
@override
String artistMonthlyListeners(String count) {
return '$count monthly listeners';
return '$count monatliche Hörer';
}
@override
String get trackMetadataTitle => 'Track Info';
String get trackMetadataTitle => 'Titel Info';
@override
String get trackMetadataArtist => 'Artist';
String get trackMetadataArtist => 'Künstler';
@override
String get trackMetadataAlbum => 'Album';
@override
String get trackMetadataDuration => 'Duration';
String get trackMetadataDuration => 'Länge';
@override
String get trackMetadataQuality => 'Quality';
String get trackMetadataQuality => 'Qualität';
@override
String get trackMetadataPath => 'File Path';
String get trackMetadataPath => 'Dateipfad';
@override
String get trackMetadataDownloadedAt => 'Downloaded';
String get trackMetadataDownloadedAt => 'Heruntergeladen';
@override
String get trackMetadataService => 'Service';
String get trackMetadataService => 'Anbieter';
@override
String get trackMetadataPlay => 'Play';
String get trackMetadataPlay => 'Abspielen';
@override
String get trackMetadataShare => 'Share';
String get trackMetadataShare => 'Teilen';
@override
String get trackMetadataDelete => 'Delete';
String get trackMetadataDelete => 'Löschen';
@override
String get trackMetadataRedownload => 'Re-download';
String get trackMetadataRedownload => 'Erneut herunterladen';
@override
String get trackMetadataOpenFolder => 'Open Folder';
String get trackMetadataOpenFolder => 'Ordner öffnen';
@override
String get setupTitle => 'Welcome to SpotiFLAC';
String get setupTitle => 'Willkommen bei SpotiFLAC';
@override
String get setupSubtitle => 'Let\'s get you started';
String get setupSubtitle => 'Los geht\'s';
@override
String get setupStoragePermission => 'Storage Permission';
String get setupStoragePermission => 'Speicherberechtigung';
@override
String get setupStoragePermissionSubtitle =>
'Required to save downloaded files';
'Benötigt um heruntergeladene Dateien zu Speichern';
@override
String get setupStoragePermissionGranted => 'Permission granted';
String get setupStoragePermissionGranted => 'Berechtigung erteilt';
@override
String get setupStoragePermissionDenied => 'Permission denied';
String get setupStoragePermissionDenied => 'Berechtigung verweigert';
@override
String get setupGrantPermission => 'Grant Permission';
String get setupGrantPermission => 'Berechtigung erlauben';
@override
String get setupDownloadLocation => 'Download Location';
String get setupDownloadLocation => 'Speicherort';
@override
String get setupChooseFolder => 'Choose Folder';
String get setupChooseFolder => 'Ordner wählen';
@override
String get setupContinue => 'Continue';
String get setupContinue => 'Fortfahren';
@override
String get setupSkip => 'Skip for now';
String get setupSkip => 'Vorerst überspringen';
@override
String get setupStorageAccessRequired => 'Storage Access Required';
String get setupStorageAccessRequired => 'Speicherzugriff erforderlich';
@override
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
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
String get setupOpenSettings => 'Open Settings';
String get setupOpenSettings => 'Einstellungen öffnen';
@override
String get setupPermissionDeniedMessage =>
'Permission denied. Please grant all permissions to continue.';
'Berechtigung verweigert. Bitte erteilen Sie alle Berechtigungen um fortzufahren.';
@override
String setupPermissionRequired(String permissionType) {
return '$permissionType Permission Required';
return '$permissionType Zugriff verweigert';
}
@override
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
String get setupSelectDownloadFolder => 'Select Download Folder';
String get setupSelectDownloadFolder => 'Wähle Download-Ordner aus';
@override
String get setupUseDefaultFolder => 'Use Default Folder?';
String get setupUseDefaultFolder => 'Als Standardordner verwenden?';
@override
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
String get setupUseDefault => 'Use Default';
String get setupUseDefault => 'Standart benutzen';
@override
String get setupDownloadLocationTitle => 'Download Location';
String get setupDownloadLocationTitle => 'Speicherort';
@override
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
String get setupAppDocumentsFolder => 'App Documents Folder';
String get setupAppDocumentsFolder => 'App-Dokumentenordner';
@override
String get setupAppDocumentsFolderSubtitle =>
'Recommended - accessible via Files app';
'Empfohlen - zugänglich über die Datei-App';
@override
String get setupChooseFromFiles => 'Choose from Files';
String get setupChooseFromFiles => 'Aus Dateien auswählen';
@override
String get setupChooseFromFilesSubtitle => 'Select iCloud or other location';
String get setupChooseFromFilesSubtitle =>
'Wählen Sie iCloud oder einen anderen Ort';
@override
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
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
String get setupDownloadInFlac => 'Spotify Titel in FLAC herunterladen';
@override
String get setupStepStorage => 'Storage';
String get setupStepStorage => 'Speicherort';
@override
String get setupStepNotification => 'Notification';
String get setupStepNotification => 'Benachrichtigung';
@override
String get setupStepFolder => 'Folder';
String get setupStepFolder => 'Ordner';
@override
String get setupStepSpotify => 'Spotify';
@override
String get setupStepPermission => 'Permission';
String get setupStepPermission => 'Berechtigung';
@override
String get setupStorageGranted => 'Storage Permission Granted!';
String get setupStorageGranted => 'Speicherberechtigung erlaubt!';
@override
String get setupStorageRequired => 'Storage Permission Required';
String get setupStorageRequired => 'Speicherzugriff erforderlich';
@override
String get setupStorageDescription =>
'SpotiFLAC needs storage permission to save your downloaded music files.';
'SpotiFLAC benötigt Speicherrechte, um die heruntergeladenen Musikdateien zu speichern.';
@override
String get setupNotificationGranted => 'Notification Permission Granted!';
String get setupNotificationGranted =>
'Benachrichtigungs-Berechtigung erteilt';
@override
String get setupNotificationEnable => 'Enable Notifications';
String get setupNotificationEnable => 'Benachrichtigungen aktivieren';
@override
String get setupNotificationDescription =>
'Get notified when downloads complete or require attention.';
'Benachrichtigt werden, wenn Downloads abgeschlossen sind.';
@override
String get setupFolderSelected => 'Download Folder Selected!';
String get setupFolderSelected => 'Download Ordner ausgewählt!';
@override
String get setupFolderChoose => 'Choose Download Folder';
String get setupFolderChoose => 'Speicherort auwählen';
@override
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
String get setupChangeFolder => 'Change Folder';
String get setupChangeFolder => 'Ordner ändern';
@override
String get setupSelectFolder => 'Select Folder';
String get setupSelectFolder => 'Ordner wählen';
@override
String get setupSpotifyApiOptional => 'Spotify API (Optional)';
String get setupSpotifyApiOptional => 'Spotify-API (optional)';
@override
String get setupSpotifyApiDescription =>
@@ -904,6 +934,11 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1437,6 +1472,35 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1549,6 +1613,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1564,6 +1637,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1792,6 +1874,38 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityLossy => 'Lossy';
@override
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override
String get enableLossyOption => 'Enable Lossy Option';
@override
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
@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
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1936,6 +2050,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -1983,6 +2104,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -2007,4 +2133,94 @@ class AppLocalizationsDe extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
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.';
}
+211
View File
@@ -109,6 +109,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -402,6 +405,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -426,6 +432,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -449,6 +470,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutSachinsenalDesc =>
'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
String get aboutDoubleDouble => 'DoubleDouble';
@@ -894,6 +919,11 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1427,6 +1457,35 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1539,6 +1598,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1554,6 +1622,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1782,6 +1859,38 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityLossy => 'Lossy';
@override
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override
String get enableLossyOption => 'Enable Lossy Option';
@override
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
@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
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1926,6 +2035,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -1973,6 +2089,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,4 +2118,94 @@ class AppLocalizationsEn extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
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
+211
View File
@@ -109,6 +109,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -402,6 +405,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -426,6 +432,21 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -449,6 +470,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutSachinsenalDesc =>
'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
String get aboutDoubleDouble => 'DoubleDouble';
@@ -894,6 +919,11 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1427,6 +1457,35 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1539,6 +1598,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1554,6 +1622,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1782,6 +1859,38 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityLossy => 'Lossy';
@override
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override
String get enableLossyOption => 'Enable Lossy Option';
@override
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
@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
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1926,6 +2035,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -1973,6 +2089,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,4 +2118,94 @@ class AppLocalizationsFr extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
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.';
}
+219 -8
View File
@@ -9,20 +9,20 @@ class AppLocalizationsHi extends AppLocalizations {
AppLocalizationsHi([String locale = 'hi']) : super(locale);
@override
String get appName => 'SpotiFLAC';
String get appName => 'SpotiFlac';
@override
String get appDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।';
@override
String get navHome => 'Home';
String get navHome => 'होम';
@override
String get navHistory => 'History';
String get navHistory => 'इतिहास';
@override
String get navSettings => 'Settings';
String get navSettings => 'विकल्प';
@override
String get navStore => 'Store';
@@ -109,6 +109,9 @@ class AppLocalizationsHi extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -181,7 +184,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get quality128 => '128 kbps';
@override
String get appearanceTitle => 'Appearance';
String get appearanceTitle => 'दिखावट';
@override
String get appearanceTheme => 'Theme';
@@ -196,10 +199,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get appearanceThemeDark => 'Dark';
@override
String get appearanceDynamicColor => 'Dynamic Color';
String get appearanceDynamicColor => 'डायनेमिक रंग';
@override
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
String get appearanceDynamicColorSubtitle => 'वॉलपेपर से रंग इस्तेमाल करें';
@override
String get appearanceAccentColor => 'Accent Color';
@@ -402,6 +405,9 @@ class AppLocalizationsHi extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -426,6 +432,21 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -449,6 +470,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get aboutSachinsenalDesc =>
'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
String get aboutDoubleDouble => 'DoubleDouble';
@@ -894,6 +919,11 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1427,6 +1457,35 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1539,6 +1598,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1554,6 +1622,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
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
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1782,6 +1859,38 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityLossy => 'Lossy';
@override
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override
String get enableLossyOption => 'Enable Lossy Option';
@override
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
@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
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1926,6 +2035,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -1973,6 +2089,11 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,4 +2118,94 @@ class AppLocalizationsHi extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
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.';
}
+211
View File
@@ -110,6 +110,9 @@ class AppLocalizationsId extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Unduhan lagu satuan akan muncul di sini';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Pengaturan';
@@ -406,6 +409,9 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutLogoArtist =>
'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Terima Kasih Khusus';
@@ -431,6 +437,21 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutFeatureRequestSubtitle =>
'Sarankan fitur baru untuk aplikasi';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Dukungan';
@@ -454,6 +475,10 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutSachinsenalDesc =>
'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
String get aboutDoubleDouble => 'DoubleDouble';
@@ -900,6 +925,11 @@ class AppLocalizationsId extends AppLocalizations {
return 'Ditemukan $count lagu di CSV. Tambahkan ke antrian unduhan?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Menambahkan \"$trackName\" ke antrian';
@@ -1437,6 +1467,35 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get sectionFileSettings => 'Pengaturan File';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Warna';
@@ -1549,6 +1608,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackReleaseDate => 'Tanggal rilis';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Diunduh';
@@ -1564,6 +1632,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
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
String get trackCopiedToClipboard => 'Disalin ke clipboard';
@@ -1794,6 +1871,38 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
@override
String get qualityLossy => 'Lossy';
@override
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override
String get enableLossyOption => 'Enable Lossy Option';
@override
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
@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
String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@@ -1939,6 +2048,13 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
@@ -1986,6 +2102,11 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Pilih lagu untuk dihapus';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Fungsi Utilitas';
@@ -2010,4 +2131,94 @@ class AppLocalizationsId extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Unduh Semua';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
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
+211
View File
@@ -109,6 +109,9 @@ class AppLocalizationsKo extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -402,6 +405,9 @@ class AppLocalizationsKo extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -426,6 +432,21 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -449,6 +470,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get aboutSachinsenalDesc =>
'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
String get aboutDoubleDouble => 'DoubleDouble';
@@ -894,6 +919,11 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1427,6 +1457,35 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1539,6 +1598,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1554,6 +1622,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
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
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1782,6 +1859,38 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityLossy => 'Lossy';
@override
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override
String get enableLossyOption => 'Enable Lossy Option';
@override
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
@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
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1926,6 +2035,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -1973,6 +2089,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,4 +2118,94 @@ class AppLocalizationsKo extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
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.';
}
+211
View File
@@ -109,6 +109,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -402,6 +405,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -426,6 +432,21 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -449,6 +470,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutSachinsenalDesc =>
'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
String get aboutDoubleDouble => 'DoubleDouble';
@@ -894,6 +919,11 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1427,6 +1457,35 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1539,6 +1598,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1554,6 +1622,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
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
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1782,6 +1859,38 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityLossy => 'Lossy';
@override
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override
String get enableLossyOption => 'Enable Lossy Option';
@override
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
@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
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1926,6 +2035,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -1973,6 +2089,11 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,4 +2118,94 @@ class AppLocalizationsNl extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
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
+214 -2
View File
@@ -114,6 +114,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Здесь будут отображаться загрузки синглов';
@override
String get historySearchHint => 'Поиск в истории...';
@override
String get settingsTitle => 'Настройки';
@@ -414,6 +417,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get aboutLogoArtist =>
'Талантливый художник, который создал наш красивый логотип приложения!';
@override
String get aboutTranslators => 'Переводчики';
@override
String get aboutSpecialThanks => 'Особая благодарность';
@@ -439,6 +445,21 @@ class AppLocalizationsRu extends AppLocalizations {
String get aboutFeatureRequestSubtitle =>
'Предложить новые функции для приложения';
@override
String get aboutTelegramChannel => 'Telegram канал';
@override
String get aboutTelegramChannelSubtitle => 'Объявления и обновления';
@override
String get aboutTelegramChat => 'Сообщество в Telegram';
@override
String get aboutTelegramChatSubtitle => 'Чат с другими пользователями';
@override
String get aboutSocial => 'Соцсети';
@override
String get aboutSupport => 'Поддержка';
@@ -462,6 +483,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get aboutSachinsenalDesc =>
'Оригинальный создатель проекта HiFi. Основатель Tidal интеграции!';
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
@override
String get aboutDoubleDouble => 'DoubleDouble';
@@ -916,6 +941,11 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Найдено $count треков в CSV. Добавить их в очередь загрузки?';
}
@override
String csvImportTracks(int count) {
return '$count треков из CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return '\"$trackName\" добавлен в очередь';
@@ -1455,6 +1485,35 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get sectionFileSettings => 'Настройки файла';
@override
String get sectionLyrics => 'Тексты песен';
@override
String get lyricsMode => 'Режим текстов песен';
@override
String get lyricsModeDescription =>
'Выберите как сохранить тексты песен при скачивании';
@override
String get lyricsModeEmbed => 'Вставить в файл';
@override
String get lyricsModeEmbedSubtitle => 'Встроить текст в метаданные FLAC';
@override
String get lyricsModeExternal => 'Внешний файл .lrc';
@override
String get lyricsModeExternalSubtitle =>
'Отдельный файл .lrc для плееров, таких, как Samsung Music';
@override
String get lyricsModeBoth => 'Оба варианта';
@override
String get lyricsModeBothSubtitle => 'Вставить и сохранить файл .lrc';
@override
String get sectionColor => 'Цвет';
@@ -1533,7 +1592,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackFileInfo => 'Информация о файле';
@override
String get trackLyrics => 'Тексты песен';
String get trackLyrics => 'Текст песни';
@override
String get trackFileNotFound => 'Файл не найден';
@@ -1545,7 +1604,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackOpenInSpotify => 'Открыть в Spotify';
@override
String get trackTrackName => 'Название трека';
String get trackTrackName => 'Название';
@override
String get trackArtist => 'Исполнитель';
@@ -1571,6 +1630,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get trackReleaseDate => 'Дата выхода';
@override
String get trackGenre => 'Жанр';
@override
String get trackLabel => 'Заголовок';
@override
String get trackCopyright => 'Авторские права';
@override
String get trackDownloaded => 'Скачано';
@@ -1588,6 +1656,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
@override
String get trackEmbedLyrics => 'Вставить текст песни';
@override
String get trackLyricsEmbedded => 'Текст успешно добавлен';
@override
String get trackInstrumental => 'Инструментальный трек';
@override
String get trackCopiedToClipboard => 'Скопировано в буфер обмена';
@@ -1820,6 +1897,38 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
@override
String get qualityLossy => 'Lossy';
@override
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override
String get enableLossyOption => 'Enable Lossy Option';
@override
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
@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
String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе';
@@ -1967,6 +2076,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get albumFolderYearAlbumSubtitle =>
'Альбомы/[2005] Название Альбома /';
@override
String get albumFolderArtistAlbumSingles => 'Исполнитель / Альбом + Синглы';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Исполнитель/Альбом и Исполнитель/Сингл/';
@override
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
@@ -2018,6 +2134,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Выберите треки для удаления';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Диск $discNumber';
}
@override
String get utilityFunctions => 'Функции утилиты';
@@ -2042,4 +2163,95 @@ class AppLocalizationsRu extends AppLocalizations {
String errorGeneric(String message) {
return 'Ошибка: $message';
}
@override
String get discographyDownload => 'Скачать дискографию';
@override
String get discographyDownloadAll => 'Скачать всё';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count треков из $albumCount релизов';
}
@override
String get discographyAlbumsOnly => 'Только альбомы';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count треков из $albumCount альбомов';
}
@override
String get discographySinglesOnly => 'Только синглы и EP';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count треков из $albumCount синглов';
}
@override
String get discographySelectAlbums => 'Выбрать альбомы...';
@override
String get discographySelectAlbumsSubtitle =>
'Выберите конкретные альбомы или синглы';
@override
String get discographyFetchingTracks => 'Получение треков...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Получение $current из $total...';
}
@override
String discographySelectedCount(int count) {
return '$count выбрано';
}
@override
String get discographyDownloadSelected => 'Скачать выбранное';
@override
String discographyAddedToQueue(int count) {
return 'Добавлено $count треков в очередь';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added добавлено, $skipped уже скачано';
}
@override
String get discographyNoAlbums => 'Нет доступных альбомов';
@override
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
+514 -1
View File
@@ -109,6 +109,9 @@ class AppLocalizationsZh extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -402,6 +405,9 @@ class AppLocalizationsZh extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -426,6 +432,21 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -449,6 +470,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get aboutSachinsenalDesc =>
'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
String get aboutDoubleDouble => 'DoubleDouble';
@@ -894,6 +919,11 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1427,6 +1457,35 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -1539,6 +1598,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -1554,6 +1622,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
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
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1782,6 +1859,38 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityLossy => 'Lossy';
@override
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
@override
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
@override
String get enableLossyOption => 'Enable Lossy Option';
@override
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
@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
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1926,6 +2035,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -1973,6 +2089,11 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -1997,6 +2118,96 @@ class AppLocalizationsZh extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
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 Chinese, as used in China (`zh_CN`).
@@ -2104,6 +2315,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -2397,6 +2611,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -2421,6 +2638,21 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -2889,6 +3121,11 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -3422,6 +3659,35 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -3534,6 +3800,15 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -3549,6 +3824,15 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
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
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -3921,6 +4205,13 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -3968,6 +4259,11 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -3992,6 +4288,72 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
@@ -4035,7 +4397,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@override
String get homeRecent => 'Recent';
String get homeRecent => '最新的';
@override
String get historyTitle => 'History';
@@ -4099,6 +4461,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
@override
String get historySearchHint => 'Search history...';
@override
String get settingsTitle => 'Settings';
@@ -4392,6 +4757,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -4416,6 +4784,21 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
@override
String get aboutSocial => 'Social';
@override
String get aboutSupport => 'Support';
@@ -4884,6 +5267,11 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -5417,6 +5805,35 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get sectionFileSettings => 'File Settings';
@override
String get sectionLyrics => 'Lyrics';
@override
String get lyricsMode => 'Lyrics Mode';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
@override
String get lyricsModeEmbed => 'Embed in file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
@override
String get lyricsModeExternal => 'External .lrc file';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
@override
String get lyricsModeBoth => 'Both';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override
String get sectionColor => 'Color';
@@ -5529,6 +5946,15 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
@@ -5544,6 +5970,15 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
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
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -5916,6 +6351,13 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -5963,6 +6405,11 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
@@ -5987,4 +6434,70 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String errorGeneric(String message) {
return 'Error: $message';
}
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
}
+389 -98
View File
@@ -1,6 +1,6 @@
{
"@@locale": "de",
"@@last_modified": "2026-01-17",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
@@ -127,11 +127,15 @@
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"historySearchHint": "Suchverlauf...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Einstellungen",
"@settingsTitle": {
"description": "Settings screen title"
},
"settingsDownload": "Download",
"settingsDownload": "Herunterladen",
"@settingsDownload": {
"description": "Settings section - download options"
},
@@ -151,7 +155,7 @@
"@settingsAbout": {
"description": "Settings section - app info"
},
"downloadTitle": "Download",
"downloadTitle": "Herunterladen",
"@downloadTitle": {
"description": "Download settings page title"
},
@@ -508,11 +512,15 @@
"@aboutOriginalCreator": {
"description": "Role description for original creator"
},
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
"aboutLogoArtist": "Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!",
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutSpecialThanks": "Special Thanks",
"aboutTranslators": "Übersetzer",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Besonderer Dank",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
},
@@ -520,39 +528,59 @@
"@aboutLinks": {
"description": "Section for external links"
},
"aboutMobileSource": "Mobile source code",
"aboutMobileSource": "Mobiler Quellcode",
"@aboutMobileSource": {
"description": "Link to mobile GitHub repo"
},
"aboutPCSource": "PC source code",
"aboutPCSource": "PC Quellcode",
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutReportIssue": "Report an issue",
"aboutReportIssue": "Problem melden",
"@aboutReportIssue": {
"description": "Link to report bugs"
},
"aboutReportIssueSubtitle": "Report any problems you encounter",
"aboutReportIssueSubtitle": "Melde jedes Problem, die dir auftreten",
"@aboutReportIssueSubtitle": {
"description": "Subtitle for report issue"
},
"aboutFeatureRequest": "Feature request",
"aboutFeatureRequest": "Feature vorschlagen",
"@aboutFeatureRequest": {
"description": "Link to suggest features"
},
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
"aboutFeatureRequestSubtitle": "Schlage neue Funktionen für die App vor",
"@aboutFeatureRequestSubtitle": {
"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": {
"description": "Section for support/donation links"
},
"aboutBuyMeCoffee": "Buy me a coffee",
"aboutBuyMeCoffee": "Spendiere mir einen Kaffee",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"aboutBuyMeCoffeeSubtitle": "Unterstütze die Entwicklung auf Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
@@ -564,11 +592,11 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
"aboutBinimumDesc": "Der Schöpfer der QQDL & HiFi API. Ohne diese API gäbe es keine Tidal-Downloads!",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"aboutSachinsenalDesc": "Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!",
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
@@ -576,7 +604,7 @@
"@aboutDoubleDouble": {
"description": "Name of Amazon API service - DO NOT TRANSLATE"
},
"aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!",
"aboutDoubleDoubleDesc": "Wundervolle API für Amazon Music Downloads.\nVielen Dank, dass Sie sie kostenlos zur Verfügung stellen!",
"@aboutDoubleDoubleDesc": {
"description": "Credit for DoubleDouble API"
},
@@ -584,11 +612,11 @@
"@aboutDabMusic": {
"description": "Name of Qobuz API service - DO NOT TRANSLATE"
},
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
"aboutDabMusicDesc": "Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!",
"@aboutDabMusicDesc": {
"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": {
"description": "App description in header card"
},
@@ -596,7 +624,7 @@
"@albumTitle": {
"description": "Album screen title"
},
"albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"albumTracks": "{count, plural,=1{1 Song} other{{count} Songs}}",
"@albumTracks": {
"description": "Album track count",
"placeholders": {
@@ -605,11 +633,11 @@
}
}
},
"albumDownloadAll": "Download All",
"albumDownloadAll": "Alle Herunterladen",
"@albumDownloadAll": {
"description": "Button to download all tracks"
},
"albumDownloadRemaining": "Download Remaining",
"albumDownloadRemaining": "Downloads verbleibend",
"@albumDownloadRemaining": {
"description": "Button to download remaining tracks"
},
@@ -617,11 +645,11 @@
"@playlistTitle": {
"description": "Playlist screen title"
},
"artistTitle": "Artist",
"artistTitle": "Künstler",
"@artistTitle": {
"description": "Artist screen title"
},
"artistAlbums": "Albums",
"artistAlbums": "Alben",
"@artistAlbums": {
"description": "Section header for artist albums"
},
@@ -629,11 +657,11 @@
"@artistSingles": {
"description": "Section header for singles/EPs"
},
"artistCompilations": "Compilations",
"artistCompilations": "Zusammenstellungen",
"@artistCompilations": {
"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": {
"description": "Artist release count",
"placeholders": {
@@ -642,11 +670,25 @@
}
}
},
"trackMetadataTitle": "Track Info",
"artistPopular": "Beliebt",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monatliche Hörer",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Titel Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
},
"trackMetadataArtist": "Artist",
"trackMetadataArtist": "Künstler",
"@trackMetadataArtist": {
"description": "Metadata field - artist name"
},
@@ -654,111 +696,111 @@
"@trackMetadataAlbum": {
"description": "Metadata field - album name"
},
"trackMetadataDuration": "Duration",
"trackMetadataDuration": "Länge",
"@trackMetadataDuration": {
"description": "Metadata field - track length"
},
"trackMetadataQuality": "Quality",
"trackMetadataQuality": "Qualität",
"@trackMetadataQuality": {
"description": "Metadata field - audio quality"
},
"trackMetadataPath": "File Path",
"trackMetadataPath": "Dateipfad",
"@trackMetadataPath": {
"description": "Metadata field - file location"
},
"trackMetadataDownloadedAt": "Downloaded",
"trackMetadataDownloadedAt": "Heruntergeladen",
"@trackMetadataDownloadedAt": {
"description": "Metadata field - download date"
},
"trackMetadataService": "Service",
"trackMetadataService": "Anbieter",
"@trackMetadataService": {
"description": "Metadata field - download service used"
},
"trackMetadataPlay": "Play",
"trackMetadataPlay": "Abspielen",
"@trackMetadataPlay": {
"description": "Action button - play track"
},
"trackMetadataShare": "Share",
"trackMetadataShare": "Teilen",
"@trackMetadataShare": {
"description": "Action button - share track"
},
"trackMetadataDelete": "Delete",
"trackMetadataDelete": "Löschen",
"@trackMetadataDelete": {
"description": "Action button - delete track"
},
"trackMetadataRedownload": "Re-download",
"trackMetadataRedownload": "Erneut herunterladen",
"@trackMetadataRedownload": {
"description": "Action button - download again"
},
"trackMetadataOpenFolder": "Open Folder",
"trackMetadataOpenFolder": "Ordner öffnen",
"@trackMetadataOpenFolder": {
"description": "Action button - open containing folder"
},
"setupTitle": "Welcome to SpotiFLAC",
"setupTitle": "Willkommen bei SpotiFLAC",
"@setupTitle": {
"description": "Setup wizard title"
},
"setupSubtitle": "Let's get you started",
"setupSubtitle": "Los geht's",
"@setupSubtitle": {
"description": "Setup wizard subtitle"
},
"setupStoragePermission": "Storage Permission",
"setupStoragePermission": "Speicherberechtigung",
"@setupStoragePermission": {
"description": "Storage permission step title"
},
"setupStoragePermissionSubtitle": "Required to save downloaded files",
"setupStoragePermissionSubtitle": "Benötigt um heruntergeladene Dateien zu Speichern",
"@setupStoragePermissionSubtitle": {
"description": "Explanation for storage permission"
},
"setupStoragePermissionGranted": "Permission granted",
"setupStoragePermissionGranted": "Berechtigung erteilt",
"@setupStoragePermissionGranted": {
"description": "Status when permission granted"
},
"setupStoragePermissionDenied": "Permission denied",
"setupStoragePermissionDenied": "Berechtigung verweigert",
"@setupStoragePermissionDenied": {
"description": "Status when permission denied"
},
"setupGrantPermission": "Grant Permission",
"setupGrantPermission": "Berechtigung erlauben",
"@setupGrantPermission": {
"description": "Button to request permission"
},
"setupDownloadLocation": "Download Location",
"setupDownloadLocation": "Speicherort",
"@setupDownloadLocation": {
"description": "Download folder step title"
},
"setupChooseFolder": "Choose Folder",
"setupChooseFolder": "Ordner wählen",
"@setupChooseFolder": {
"description": "Button to pick folder"
},
"setupContinue": "Continue",
"setupContinue": "Fortfahren",
"@setupContinue": {
"description": "Continue to next step button"
},
"setupSkip": "Skip for now",
"setupSkip": "Vorerst überspringen",
"@setupSkip": {
"description": "Skip current step button"
},
"setupStorageAccessRequired": "Storage Access Required",
"setupStorageAccessRequired": "Speicherzugriff erforderlich",
"@setupStorageAccessRequired": {
"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": {
"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": {
"description": "Android 11+ specific explanation"
},
"setupOpenSettings": "Open Settings",
"setupOpenSettings": "Einstellungen öffnen",
"@setupOpenSettings": {
"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": {
"description": "Error when permission denied"
},
"setupPermissionRequired": "{permissionType} Permission Required",
"setupPermissionRequired": "{permissionType} Zugriff verweigert",
"@setupPermissionRequired": {
"description": "Generic permission required title",
"placeholders": {
@@ -768,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": {
"description": "Generic permission required message",
"placeholders": {
@@ -777,63 +819,63 @@
}
}
},
"setupSelectDownloadFolder": "Select Download Folder",
"setupSelectDownloadFolder": "Wähle Download-Ordner aus",
"@setupSelectDownloadFolder": {
"description": "Folder selection step title"
},
"setupUseDefaultFolder": "Use Default Folder?",
"setupUseDefaultFolder": "Als Standardordner verwenden?",
"@setupUseDefaultFolder": {
"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": {
"description": "Prompt when no folder selected"
},
"setupUseDefault": "Use Default",
"setupUseDefault": "Standart benutzen",
"@setupUseDefault": {
"description": "Button to use default folder"
},
"setupDownloadLocationTitle": "Download Location",
"setupDownloadLocationTitle": "Speicherort",
"@setupDownloadLocationTitle": {
"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": {
"description": "iOS-specific folder info"
},
"setupAppDocumentsFolder": "App Documents Folder",
"setupAppDocumentsFolder": "App-Dokumentenordner",
"@setupAppDocumentsFolder": {
"description": "iOS documents folder option"
},
"setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app",
"setupAppDocumentsFolderSubtitle": "Empfohlen - zugänglich über die Datei-App",
"@setupAppDocumentsFolderSubtitle": {
"description": "Subtitle for documents folder"
},
"setupChooseFromFiles": "Choose from Files",
"setupChooseFromFiles": "Aus Dateien auswählen",
"@setupChooseFromFiles": {
"description": "iOS file picker option"
},
"setupChooseFromFilesSubtitle": "Select iCloud or other location",
"setupChooseFromFilesSubtitle": "Wählen Sie iCloud oder einen anderen Ort",
"@setupChooseFromFilesSubtitle": {
"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": {
"description": "iOS folder selection warning"
},
"setupDownloadInFlac": "Download Spotify tracks in FLAC",
"setupDownloadInFlac": "Spotify Titel in FLAC herunterladen",
"@setupDownloadInFlac": {
"description": "App tagline in setup"
},
"setupStepStorage": "Storage",
"setupStepStorage": "Speicherort",
"@setupStepStorage": {
"description": "Setup step indicator - storage"
},
"setupStepNotification": "Notification",
"setupStepNotification": "Benachrichtigung",
"@setupStepNotification": {
"description": "Setup step indicator - notification"
},
"setupStepFolder": "Folder",
"setupStepFolder": "Ordner",
"@setupStepFolder": {
"description": "Setup step indicator - folder"
},
@@ -841,55 +883,55 @@
"@setupStepSpotify": {
"description": "Setup step indicator - Spotify API"
},
"setupStepPermission": "Permission",
"setupStepPermission": "Berechtigung",
"@setupStepPermission": {
"description": "Setup step indicator - permission"
},
"setupStorageGranted": "Storage Permission Granted!",
"setupStorageGranted": "Speicherberechtigung erlaubt!",
"@setupStorageGranted": {
"description": "Success message for storage permission"
},
"setupStorageRequired": "Storage Permission Required",
"setupStorageRequired": "Speicherzugriff erforderlich",
"@setupStorageRequired": {
"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": {
"description": "Explanation for storage permission"
},
"setupNotificationGranted": "Notification Permission Granted!",
"setupNotificationGranted": "Benachrichtigungs-Berechtigung erteilt",
"@setupNotificationGranted": {
"description": "Success message for notification permission"
},
"setupNotificationEnable": "Enable Notifications",
"setupNotificationEnable": "Benachrichtigungen aktivieren",
"@setupNotificationEnable": {
"description": "Button to enable notifications"
},
"setupNotificationDescription": "Get notified when downloads complete or require attention.",
"setupNotificationDescription": "Benachrichtigt werden, wenn Downloads abgeschlossen sind.",
"@setupNotificationDescription": {
"description": "Explanation for notifications"
},
"setupFolderSelected": "Download Folder Selected!",
"setupFolderSelected": "Download Ordner ausgewählt!",
"@setupFolderSelected": {
"description": "Success message for folder selection"
},
"setupFolderChoose": "Choose Download Folder",
"setupFolderChoose": "Speicherort auwählen",
"@setupFolderChoose": {
"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": {
"description": "Explanation for folder selection"
},
"setupChangeFolder": "Change Folder",
"setupChangeFolder": "Ordner ändern",
"@setupChangeFolder": {
"description": "Button to change selected folder"
},
"setupSelectFolder": "Select Folder",
"setupSelectFolder": "Ordner wählen",
"@setupSelectFolder": {
"description": "Button to select folder"
},
"setupSpotifyApiOptional": "Spotify API (Optional)",
"setupSpotifyApiOptional": "Spotify-API (optional)",
"@setupSpotifyApiOptional": {
"description": "Spotify API step title"
},
@@ -1108,6 +1150,15 @@
"description": "Dialog title - import CSV playlist"
},
"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": {
"description": "Dialog message - import playlist confirmation",
"placeholders": {
@@ -1837,6 +1888,42 @@
"@sectionFileSettings": {
"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": {
"description": "Settings section header"
@@ -1851,27 +1938,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -1995,6 +2070,18 @@
"@trackReleaseDate": {
"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": {
"description": "Metadata label - download date"
@@ -2015,6 +2102,18 @@
"@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": {
"description": "Snackbar - content copied"
@@ -2326,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": {
"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": {
"description": "Note about quality availability"
@@ -2514,6 +2633,14 @@
"@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": {
"description": "Button - delete selected tracks"
@@ -2570,8 +2697,172 @@
"@downloadedAlbumSelectToDelete": {
"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": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"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"
}
}
+179 -3
View File
@@ -75,8 +75,10 @@
"@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"},
"historyNoSingles": "No single downloads",
"@historyNoSingles": {"description": "Empty state when filtering singles"},
"historyNoSinglesSubtitle": "Single track downloads will appear here",
"historyNoSinglesSubtitle": "Single track downloads will appear here",
"@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"},
"historySearchHint": "Search history...",
"@historySearchHint": {"description": "Search bar placeholder in history"},
"settingsTitle": "Settings",
"@settingsTitle": {"description": "Settings screen title"},
@@ -290,6 +292,8 @@
"@aboutOriginalCreator": {"description": "Role description for original creator"},
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
"@aboutLogoArtist": {"description": "Role description for logo artist"},
"aboutTranslators": "Translators",
"@aboutTranslators": {"description": "Section for translators"},
"aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": {"description": "Section for special thanks"},
"aboutLinks": "Links",
@@ -302,10 +306,20 @@
"@aboutReportIssue": {"description": "Link to report bugs"},
"aboutReportIssueSubtitle": "Report any problems you encounter",
"@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"},
"aboutFeatureRequest": "Feature request",
"aboutFeatureRequest": "Feature request",
"@aboutFeatureRequest": {"description": "Link to suggest features"},
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
"@aboutFeatureRequestSubtitle": {"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": {"description": "Section for support/donation links"},
"aboutBuyMeCoffee": "Buy me a coffee",
@@ -320,6 +334,8 @@
"@aboutBinimumDesc": {"description": "Credit description for binimum"},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"@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": {"description": "Name of Amazon API service - DO NOT TRANSLATE"},
"aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!",
@@ -617,6 +633,13 @@
"dialogImportPlaylistTitle": "Import Playlist",
"@dialogImportPlaylistTitle": {"description": "Dialog title - import CSV playlist"},
"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": {
"description": "Dialog message - import playlist confirmation",
"placeholders": {
@@ -1049,6 +1072,26 @@
"@sectionAudioQuality": {"description": "Settings section header"},
"sectionFileSettings": "File Settings",
"@sectionFileSettings": {"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": {"description": "Settings section header"},
"sectionTheme": "Theme",
@@ -1131,6 +1174,12 @@
"@trackAudioQuality": {"description": "Metadata label - audio quality"},
"trackReleaseDate": "Release date",
"@trackReleaseDate": {"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": {"description": "Metadata label - download date"},
"trackCopyLyrics": "Copy lyrics",
@@ -1141,6 +1190,12 @@
"@trackLyricsTimeout": {"description": "Message when lyrics request times out"},
"trackLyricsLoadFailed": "Failed to load lyrics",
"@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": {"description": "Snackbar - content copied"},
"trackDeleteConfirmTitle": "Remove from device?",
@@ -1320,6 +1375,26 @@
"@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"},
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
"@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"},
"qualityLossy": "Lossy",
"@qualityLossy": {"description": "Quality option - lossy format (MP3/Opus)"},
"qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)",
"@qualityLossyMp3Subtitle": {"description": "Technical spec for lossy MP3"},
"qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)",
"@qualityLossyOpusSubtitle": {"description": "Technical spec for lossy Opus"},
"enableLossyOption": "Enable Lossy Option",
"@enableLossyOption": {"description": "Setting - enable lossy quality option"},
"enableLossyOptionSubtitleOn": "Lossy quality option is available",
"@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": {"description": "Note about quality availability"},
@@ -1420,6 +1495,10 @@
"@albumFolderYearAlbum": {"description": "Album folder option with year"},
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
"@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": {"description": "Button - delete selected tracks"},
@@ -1459,6 +1538,13 @@
},
"downloadedAlbumSelectToDelete": "Select tracks to delete",
"@downloadedAlbumSelectToDelete": {"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": {"description": "Extension capability - utility functions"},
@@ -1485,5 +1571,95 @@
"placeholders": {
"message": {"type": "String", "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"},
"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"}
}
File diff suppressed because it is too large Load Diff
+306 -15
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings",
"@settingsTitle": {
"description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": {
"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": {
"description": "Section for support/donation links"
@@ -642,6 +670,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1108,6 +1150,15 @@
"description": "Dialog title - import CSV playlist"
},
"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": {
"description": "Dialog message - import playlist confirmation",
"placeholders": {
@@ -1837,6 +1888,42 @@
"@sectionFileSettings": {
"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": {
"description": "Settings section header"
@@ -1851,27 +1938,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -1995,6 +2070,18 @@
"@trackReleaseDate": {
"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": {
"description": "Metadata label - download date"
@@ -2015,6 +2102,18 @@
"@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": {
"description": "Snackbar - content copied"
@@ -2326,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": {
"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": {
"description": "Note about quality availability"
@@ -2514,6 +2633,14 @@
"@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": {
"description": "Button - delete selected tracks"
@@ -2570,8 +2697,172 @@
"@downloadedAlbumSelectToDelete": {
"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": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"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"
}
}
+314 -23
View File
@@ -1,23 +1,23 @@
{
"@@locale": "hi",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appName": "SpotiFlac",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
"appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"appDescription": "स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।",
"@appDescription": {
"description": "App description shown in about page"
},
"navHome": "Home",
"navHome": "होम",
"@navHome": {
"description": "Bottom navigation - Home tab"
},
"navHistory": "History",
"navHistory": "इतिहास",
"@navHistory": {
"description": "Bottom navigation - History tab"
},
"navSettings": "Settings",
"navSettings": "विकल्प",
"@navSettings": {
"description": "Bottom navigation - Settings tab"
},
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings",
"@settingsTitle": {
"description": "Settings screen title"
@@ -219,7 +223,7 @@
"@quality128": {
"description": "Audio quality option - 128kbps MP3"
},
"appearanceTitle": "Appearance",
"appearanceTitle": "दिखावट",
"@appearanceTitle": {
"description": "Appearance settings page title"
},
@@ -239,11 +243,11 @@
"@appearanceThemeDark": {
"description": "Dark theme"
},
"appearanceDynamicColor": "Dynamic Color",
"appearanceDynamicColor": "डायनेमिक रंग",
"@appearanceDynamicColor": {
"description": "Material You dynamic colors"
},
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
"appearanceDynamicColorSubtitle": "वॉलपेपर से रंग इस्तेमाल करें",
"@appearanceDynamicColorSubtitle": {
"description": "Subtitle for dynamic color"
},
@@ -512,6 +516,10 @@
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": {
"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": {
"description": "Section for support/donation links"
@@ -642,6 +670,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1108,6 +1150,15 @@
"description": "Dialog title - import CSV playlist"
},
"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": {
"description": "Dialog message - import playlist confirmation",
"placeholders": {
@@ -1837,6 +1888,42 @@
"@sectionFileSettings": {
"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": {
"description": "Settings section header"
@@ -1851,27 +1938,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -1995,6 +2070,18 @@
"@trackReleaseDate": {
"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": {
"description": "Metadata label - download date"
@@ -2015,6 +2102,18 @@
"@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": {
"description": "Snackbar - content copied"
@@ -2326,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": {
"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": {
"description": "Note about quality availability"
@@ -2514,6 +2633,14 @@
"@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": {
"description": "Button - delete selected tracks"
@@ -2570,8 +2697,172 @@
"@downloadedAlbumSelectToDelete": {
"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": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"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"
}
}
+2729 -542
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
+306 -15
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings",
"@settingsTitle": {
"description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": {
"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": {
"description": "Section for support/donation links"
@@ -642,6 +670,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1108,6 +1150,15 @@
"description": "Dialog title - import CSV playlist"
},
"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": {
"description": "Dialog message - import playlist confirmation",
"placeholders": {
@@ -1837,6 +1888,42 @@
"@sectionFileSettings": {
"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": {
"description": "Settings section header"
@@ -1851,27 +1938,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -1995,6 +2070,18 @@
"@trackReleaseDate": {
"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": {
"description": "Metadata label - download date"
@@ -2015,6 +2102,18 @@
"@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": {
"description": "Snackbar - content copied"
@@ -2326,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": {
"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": {
"description": "Note about quality availability"
@@ -2514,6 +2633,14 @@
"@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": {
"description": "Button - delete selected tracks"
@@ -2570,8 +2697,172 @@
"@downloadedAlbumSelectToDelete": {
"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": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"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"
}
}
+306 -15
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings",
"@settingsTitle": {
"description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": {
"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": {
"description": "Section for support/donation links"
@@ -642,6 +670,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1108,6 +1150,15 @@
"description": "Dialog title - import CSV playlist"
},
"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": {
"description": "Dialog message - import playlist confirmation",
"placeholders": {
@@ -1837,6 +1888,42 @@
"@sectionFileSettings": {
"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": {
"description": "Settings section header"
@@ -1851,27 +1938,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -1995,6 +2070,18 @@
"@trackReleaseDate": {
"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": {
"description": "Metadata label - download date"
@@ -2015,6 +2102,18 @@
"@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": {
"description": "Snackbar - content copied"
@@ -2326,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": {
"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": {
"description": "Note about quality availability"
@@ -2514,6 +2633,14 @@
"@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": {
"description": "Button - delete selected tracks"
@@ -2570,8 +2697,172 @@
"@downloadedAlbumSelectToDelete": {
"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": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"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"
}
}
File diff suppressed because it is too large Load Diff
+255 -2
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"historySearchHint": "Поиск в истории...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Настройки",
"@settingsTitle": {
"description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutTranslators": "Переводчики",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Особая благодарность",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": {
"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": {
"description": "Section for support/donation links"
@@ -1122,6 +1150,15 @@
"description": "Dialog title - import CSV playlist"
},
"dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?",
"csvImportTracks": "{count} треков из CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {
"type": "int"
}
}
},
"@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation",
"placeholders": {
@@ -1851,6 +1888,42 @@
"@sectionFileSettings": {
"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": {
"description": "Settings section header"
@@ -1945,7 +2018,7 @@
"@trackFileInfo": {
"description": "Tab title - file information"
},
"trackLyrics": "Тексты песен",
"trackLyrics": "Текст песни",
"@trackLyrics": {
"description": "Tab title - lyrics"
},
@@ -1961,7 +2034,7 @@
"@trackOpenInSpotify": {
"description": "Action - open track in Spotify app"
},
"trackTrackName": "Название трека",
"trackTrackName": "Название",
"@trackTrackName": {
"description": "Metadata label - track title"
},
@@ -1997,6 +2070,18 @@
"@trackReleaseDate": {
"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": {
"description": "Metadata label - download date"
@@ -2017,6 +2102,18 @@
"@trackLyricsLoadFailed": {
"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": {
"description": "Snackbar - content copied"
@@ -2328,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": {
"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": {
"description": "Note about quality availability"
@@ -2516,6 +2633,14 @@
"@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example"
},
"albumFolderArtistAlbumSingles": "Исполнитель / Альбом + Синглы",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Исполнитель/Альбом и Исполнитель/Сингл/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"downloadedAlbumDeleteSelected": "Удалить выбранные",
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
@@ -2572,6 +2697,16 @@
"@downloadedAlbumSelectToDelete": {
"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": {
"description": "Extension capability - utility functions"
@@ -2611,5 +2746,123 @@
"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"
}
}
File diff suppressed because it is too large Load Diff
+253
View File
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings",
"@settingsTitle": {
"description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": {
"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": {
"description": "Section for support/donation links"
@@ -1122,6 +1150,15 @@
"description": "Dialog title - import CSV playlist"
},
"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": {
"description": "Dialog message - import playlist confirmation",
"placeholders": {
@@ -1851,6 +1888,42 @@
"@sectionFileSettings": {
"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": {
"description": "Settings section header"
@@ -1997,6 +2070,18 @@
"@trackReleaseDate": {
"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": {
"description": "Metadata label - download date"
@@ -2017,6 +2102,18 @@
"@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": {
"description": "Snackbar - content copied"
@@ -2328,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": {
"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": {
"description": "Note about quality availability"
@@ -2516,6 +2633,14 @@
"@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": {
"description": "Button - delete selected tracks"
@@ -2572,6 +2697,16 @@
"@downloadedAlbumSelectToDelete": {
"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": {
"description": "Extension capability - utility functions"
@@ -2611,5 +2746,123 @@
"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"
}
}
+254 -1
View File
@@ -51,7 +51,7 @@
"@homeSupports": {
"description": "Info text about supported URL types"
},
"homeRecent": "Recent",
"homeRecent": "最新的",
"@homeRecent": {
"description": "Section header for recent searches"
},
@@ -127,6 +127,10 @@
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings",
"@settingsTitle": {
"description": "Settings screen title"
@@ -512,6 +516,10 @@
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutTranslators": "Translators",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
@@ -544,6 +552,26 @@
"@aboutFeatureRequestSubtitle": {
"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": {
"description": "Section for support/donation links"
@@ -1122,6 +1150,15 @@
"description": "Dialog title - import CSV playlist"
},
"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": {
"description": "Dialog message - import playlist confirmation",
"placeholders": {
@@ -1851,6 +1888,42 @@
"@sectionFileSettings": {
"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": {
"description": "Settings section header"
@@ -1997,6 +2070,18 @@
"@trackReleaseDate": {
"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": {
"description": "Metadata label - download date"
@@ -2017,6 +2102,18 @@
"@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": {
"description": "Snackbar - content copied"
@@ -2328,6 +2425,26 @@
"@qualityHiResFlacMaxSubtitle": {
"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": {
"description": "Note about quality availability"
@@ -2516,6 +2633,14 @@
"@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": {
"description": "Button - delete selected tracks"
@@ -2572,6 +2697,16 @@
"@downloadedAlbumSelectToDelete": {
"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": {
"description": "Extension capability - utility functions"
@@ -2611,5 +2746,123 @@
"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"
}
}
+10 -24
View File
@@ -1,48 +1,34 @@
// GENERATED FILE - DO NOT EDIT
// Generated by: dart run tool/check_translations.dart 0
// Only languages with >= 0% translation completion are included.
// Generated by: dart run tool/check_translations.dart 70
// Only languages with >= 70% translation completion are included.
// Translation is measured by comparing VALUES (not just key existence).
//
// To regenerate, run: dart run tool/check_translations.dart 0
// To regenerate, run: dart run tool/check_translations.dart 70
import 'package:flutter/widgets.dart';
/// Minimum translation completion threshold used to filter languages.
const int translationThreshold = 0;
const int translationThreshold = 70;
/// List of locales that meet the translation threshold.
/// Only these languages will be available in the app.
const List<Locale> filteredSupportedLocales = <Locale>[
Locale('en'),
Locale('ru'),
Locale('es', 'ES'),
Locale('id'),
Locale('pt', 'PT'),
Locale('ja'),
Locale('de'),
Locale('es'),
Locale('fr'),
Locale('hi'),
Locale('ko'),
Locale('nl'),
Locale('pt'),
Locale('zh'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
Locale('tr'),
];
/// Set of locale codes for quick lookup.
const Set<String> filteredLocaleCodes = <String>{
'en',
'ru',
'es_ES',
'id',
'pt_PT',
'ja',
'de',
'es',
'fr',
'hi',
'ko',
'nl',
'pt',
'zh',
'zh_CN',
'zh_TW',
'tr',
};
+9 -3
View File
@@ -7,13 +7,18 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await NotificationService().initialize();
await CoverCacheManager.initialize();
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
await ShareIntentService().initialize();
await Future.wait([
NotificationService().initialize(),
ShareIntentService().initialize(),
]);
runApp(
ProviderScope(
@@ -38,6 +43,8 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
void initState() {
super.initState();
_initializeExtensions();
// Trigger history provider initialization without subscribing to updates.
ref.read(downloadHistoryProvider);
}
Future<void> _initializeExtensions() async {
@@ -57,7 +64,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override
Widget build(BuildContext context) {
ref.watch(downloadHistoryProvider);
return widget.child;
}
}
+6 -9
View File
@@ -3,23 +3,21 @@ import 'package:spotiflac_android/models/track.dart';
part 'download_item.g.dart';
/// Download status enum
enum DownloadStatus {
queued,
downloading,
finalizing, // Embedding metadata, cover, lyrics
finalizing,
completed,
failed,
skipped,
}
/// Error type enum for better error handling
enum DownloadErrorType {
unknown,
notFound, // Track not found on any service
rateLimit, // Rate limited by service
network, // Network/connection error
permission, // File/folder permission error
notFound,
rateLimit,
network,
permission,
}
@JsonSerializable()
@@ -29,7 +27,7 @@ class DownloadItem {
final String service;
final DownloadStatus status;
final double progress;
final double speedMBps; // Download speed in MB/s
final double speedMBps;
final String? filePath;
final String? error;
final DownloadErrorType? errorType;
@@ -78,7 +76,6 @@ class DownloadItem {
);
}
/// Get user-friendly error message based on error type
String get errorMessage {
if (error == null) return '';
+59 -39
View File
@@ -12,25 +12,30 @@ class AppSettings {
final bool embedLyrics;
final bool maxQualityCover;
final bool isFirstLaunch;
final int concurrentDownloads; // 1 = sequential (default), max 3
final bool checkForUpdates; // Check for updates on app start
final String updateChannel; // stable, preview
final bool hasSearchedBefore; // Hide helper text after first search
final String folderOrganization; // none, artist, album, artist_album
final String historyViewMode; // list, grid
final String historyFilterMode; // all, albums, singles
final bool askQualityBeforeDownload; // Show quality picker before each download
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
final String metadataSource; // spotify, deezer - source for search and metadata
final bool enableLogging; // Enable detailed logging for debugging
final bool useExtensionProviders; // Use extension providers for downloads when available
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
final bool separateSingles; // Separate singles/EPs into their own folder
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
final bool showExtensionStore; // Show Extension Store tab in navigation
final String locale; // App language: 'system', 'en', 'id', etc.
final int concurrentDownloads;
final bool checkForUpdates;
final String updateChannel;
final bool hasSearchedBefore;
final String folderOrganization;
final String historyViewMode;
final String historyFilterMode;
final bool askQualityBeforeDownload;
final String spotifyClientId;
final String spotifyClientSecret;
final bool useCustomSpotifyCredentials;
final String metadataSource;
final bool enableLogging;
final bool useExtensionProviders;
final String? searchProvider;
final bool separateSingles;
final String albumFolderStructure;
final bool showExtensionStore;
final String locale;
final bool enableLossyOption;
final String lossyFormat;
final String lossyBitrate; // e.g., 'mp3_320', 'mp3_256', 'mp3_192', 'mp3_128', 'opus_128', 'opus_96', 'opus_64'
final String lyricsMode;
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
const AppSettings({
this.defaultService = 'tidal',
@@ -41,25 +46,30 @@ class AppSettings {
this.embedLyrics = true,
this.maxQualityCover = true,
this.isFirstLaunch = true,
this.concurrentDownloads = 1, // Default: sequential (off)
this.checkForUpdates = true, // Default: enabled
this.updateChannel = 'stable', // Default: stable releases only
this.hasSearchedBefore = false, // Default: show helper text
this.folderOrganization = 'none', // Default: no folder organization
this.historyViewMode = 'grid', // Default: grid view
this.historyFilterMode = 'all', // Default: show all
this.askQualityBeforeDownload = true, // Default: ask quality before download
this.spotifyClientId = '', // Default: use built-in credentials
this.spotifyClientSecret = '', // Default: use built-in credentials
this.useCustomSpotifyCredentials = true, // Default: use custom if set
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
this.enableLogging = false, // Default: disabled for performance
this.useExtensionProviders = true, // Default: use extensions when available
this.searchProvider, // Default: null (use Deezer/Spotify)
this.separateSingles = false, // Default: disabled
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
this.showExtensionStore = true, // Default: show store
this.locale = 'system', // Default: follow system language
this.concurrentDownloads = 1,
this.checkForUpdates = true,
this.updateChannel = 'stable',
this.hasSearchedBefore = false,
this.folderOrganization = 'none',
this.historyViewMode = 'grid',
this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true,
this.spotifyClientId = '',
this.spotifyClientSecret = '',
this.useCustomSpotifyCredentials = true,
this.metadataSource = 'deezer',
this.enableLogging = false,
this.useExtensionProviders = true,
this.searchProvider,
this.separateSingles = false,
this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true,
this.locale = 'system',
this.enableLossyOption = false,
this.lossyFormat = 'mp3',
this.lossyBitrate = 'mp3_320',
this.lyricsMode = 'embed',
this.useAllFilesAccess = false,
});
AppSettings copyWith({
@@ -86,11 +96,16 @@ class AppSettings {
bool? enableLogging,
bool? useExtensionProviders,
String? searchProvider,
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
bool clearSearchProvider = false,
bool? separateSingles,
String? albumFolderStructure,
bool? showExtensionStore,
String? locale,
bool? enableLossyOption,
String? lossyFormat,
String? lossyBitrate,
String? lyricsMode,
bool? useAllFilesAccess,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -120,6 +135,11 @@ class AppSettings {
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
enableLossyOption: enableLossyOption ?? this.enableLossyOption,
lossyFormat: lossyFormat ?? this.lossyFormat,
lossyBitrate: lossyBitrate ?? this.lossyBitrate,
lyricsMode: lyricsMode ?? this.lyricsMode,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
);
}
+10
View File
@@ -36,6 +36,11 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
enableLossyOption: json['enableLossyOption'] as bool? ?? false,
lossyFormat: json['lossyFormat'] as String? ?? 'mp3',
lossyBitrate: json['lossyBitrate'] as String? ?? 'mp3_320',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -67,4 +72,9 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
'enableLossyOption': instance.enableLossyOption,
'lossyFormat': instance.lossyFormat,
'lossyBitrate': instance.lossyBitrate,
'lyricsMode': instance.lyricsMode,
'useAllFilesAccess': instance.useAllFilesAccess,
};
-6
View File
@@ -9,7 +9,6 @@ const String kUseAmoledKey = 'use_amoled';
/// Default Spotify green color for fallback
const int kDefaultSeedColor = 0xFF1DB954;
/// Theme settings model for Material Expressive 3
class ThemeSettings {
final ThemeMode themeMode;
final bool useDynamicColor;
@@ -23,10 +22,8 @@ class ThemeSettings {
this.useAmoled = false,
});
/// Get seed color as Color object
Color get seedColor => Color(seedColorValue);
/// Create a copy with updated values
ThemeSettings copyWith({
ThemeMode? themeMode,
bool? useDynamicColor,
@@ -41,7 +38,6 @@ class ThemeSettings {
);
}
/// Convert to JSON map for persistence
Map<String, dynamic> toJson() => {
kThemeModeKey: themeMode.name,
kUseDynamicColorKey: useDynamicColor,
@@ -49,7 +45,6 @@ class ThemeSettings {
kUseAmoledKey: useAmoled,
};
/// Create from JSON map
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
return ThemeSettings(
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
@@ -74,7 +69,6 @@ class ThemeSettings {
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
}
/// Helper to convert string to ThemeMode
ThemeMode _themeModeFromString(String? value) {
if (value == null) return ThemeMode.system;
return ThemeMode.values.firstWhere(
+3 -10
View File
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
part 'track.g.dart';
/// Track model representing a music track
@JsonSerializable()
class Track {
final String id;
@@ -18,9 +17,9 @@ class Track {
final String? releaseDate;
final String? deezerId;
final ServiceAvailability? availability;
final String? source; // Extension ID that provided this track (null for built-in sources)
final String? albumType; // album, single, ep, compilation (from metadata API)
final String? itemType; // track, album, playlist - for extension search results
final String? source;
final String? albumType;
final String? itemType;
const Track({
required this.id,
@@ -41,25 +40,19 @@ class Track {
this.itemType,
});
/// Check if this track is a single (based on album_type metadata)
bool get isSingle => albumType == 'single' || albumType == 'ep';
/// Check if this is an album item (not a track)
bool get isAlbumItem => itemType == 'album';
/// Check if this is a playlist item (not a track)
bool get isPlaylistItem => itemType == 'playlist';
/// Check if this is an artist item (not a track)
bool get isArtistItem => itemType == 'artist';
/// Check if this is a collection (album, playlist, or artist)
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this);
/// Check if this track is from an extension
bool get isFromExtension => source != null && source!.isNotEmpty;
}
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('ExploreProvider');
/// Represents an item in a Spotify home section
class ExploreItem {
final String id;
final String uri;
final String type; // track, album, playlist, artist, station
final String name;
final String artists;
final String? description;
final String? coverUrl;
final String? providerId;
final String? albumId;
final String? albumName;
final int durationMs;
const ExploreItem({
required this.id,
required this.uri,
required this.type,
required this.name,
required this.artists,
this.description,
this.coverUrl,
this.providerId,
this.albumId,
this.albumName,
this.durationMs = 0,
});
factory ExploreItem.fromJson(Map<String, dynamic> json) {
return ExploreItem(
id: json['id'] as String? ?? '',
uri: json['uri'] as String? ?? '',
type: json['type'] as String? ?? 'track',
name: json['name'] as String? ?? '',
artists: json['artists'] as String? ?? '',
description: json['description'] as String?,
coverUrl: json['cover_url'] as String?,
providerId: json['provider_id'] as String?,
albumId: json['album_id'] as String?,
albumName: json['album_name'] as String?,
durationMs: json['duration_ms'] as int? ?? 0,
);
}
}
/// Represents a section in Spotify home feed
class ExploreSection {
final String uri;
final String title;
final List<ExploreItem> items;
final bool isYTMusicQuickPicks;
const ExploreSection({
required this.uri,
required this.title,
required this.items,
this.isYTMusicQuickPicks = false,
});
factory ExploreSection.fromJson(Map<String, dynamic> json) {
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(
uri: json['uri'] as String? ?? '',
title: json['title'] as String? ?? '',
items: items,
isYTMusicQuickPicks: isQuickPicks,
);
}
}
/// State for explore/home feed
class ExploreState {
final bool isLoading;
final String? error;
final String? greeting;
final List<ExploreSection> sections;
final DateTime? lastFetched;
const ExploreState({
this.isLoading = false,
this.error,
this.greeting,
this.sections = const [],
this.lastFetched,
});
bool get hasContent => sections.isNotEmpty;
ExploreState copyWith({
bool? isLoading,
String? error,
String? greeting,
List<ExploreSection>? sections,
DateTime? lastFetched,
}) {
return ExploreState(
isLoading: isLoading ?? this.isLoading,
error: error,
greeting: greeting ?? this.greeting,
sections: sections ?? this.sections,
lastFetched: lastFetched ?? this.lastFetched,
);
}
}
/// 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
class ExploreNotifier extends Notifier<ExploreState> {
@override
ExploreState build() {
return const ExploreState();
}
/// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// Don't refetch if we have data and it's less than 5 minutes old
if (!forceRefresh &&
state.hasContent &&
state.lastFetched != null &&
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
_log.d('Using cached home feed');
return;
}
if (state.isLoading) {
_log.d('Home feed fetch already in progress');
return;
}
state = state.copyWith(isLoading: true, error: null);
try {
// Find any extension with homeFeed capability
final extState = ref.read(extensionProvider);
_log.d('Extensions count: ${extState.extensions.length}');
// Look for extensions with homeFeed capability (prefer spotify-web)
Extension? targetExt;
for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) {
continue;
}
if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension;
if (extension.id == 'spotify-web') {
break;
}
}
}
if (targetExt == null) {
_log.w('No extension with homeFeed capability found');
state = state.copyWith(
isLoading: false,
error: 'No extension with home feed support enabled',
);
return;
}
_log.i('Fetching home feed from ${targetExt.id}...');
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
if (result == null) {
state = state.copyWith(
isLoading: false,
error: 'Failed to fetch home feed',
);
return;
}
final success = result['success'] as bool? ?? false;
_log.d('getExtensionHomeFeed success=$success');
if (!success) {
final error = result['error'] as String? ?? 'Unknown error';
state = state.copyWith(
isLoading: false,
error: error,
);
return;
}
final greeting = result['greeting'] as String?;
final sectionsData = result['sections'] as List<dynamic>? ?? [];
final sections = sectionsData
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
.toList();
_log.i('Fetched ${sections.length} sections');
// Debug: log first section items
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
final firstItem = sections.first.items.first;
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
}
// Always use local device time for greeting to avoid timezone issues
// Extension greeting may use wrong timezone (UTC or Spotify account timezone)
final localGreeting = _getLocalGreeting();
_log.d('Greeting from extension: $greeting, using local: $localGreeting');
state = ExploreState(
isLoading: false,
greeting: localGreeting,
sections: sections,
lastFetched: DateTime.now(),
);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
/// Clear cached data
void clear() {
state = const ExploreState();
}
/// Refresh home feed
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
}
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
return ExploreNotifier();
});
+49 -39
View File
@@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
final _log = AppLogger('ExtensionProvider');
/// Represents an installed extension
class Extension {
final String id;
final String name;
@@ -14,19 +13,20 @@ class Extension {
final String author;
final String description;
final bool enabled;
final String status; // 'loaded', 'error', 'disabled'
final String status;
final String? errorMessage;
final String? iconPath; // Path to extension icon
final String? iconPath;
final List<String> permissions;
final List<ExtensionSetting> settings;
final List<QualityOption> qualityOptions; // Custom quality options for download providers
final List<QualityOption> qualityOptions;
final bool hasMetadataProvider;
final bool hasDownloadProvider;
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final SearchBehavior? searchBehavior; // Custom search behavior
final URLHandler? urlHandler; // Custom URL handling
final TrackMatching? trackMatching; // Custom track matching
final PostProcessing? postProcessing; // Post-processing hooks
final SearchBehavior? searchBehavior;
final URLHandler? urlHandler;
final TrackMatching? trackMatching;
final PostProcessing? postProcessing;
final Map<String, dynamic> capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
const Extension({
required this.id,
@@ -49,6 +49,7 @@ class Extension {
this.urlHandler,
this.trackMatching,
this.postProcessing,
this.capabilities = const {},
});
factory Extension.fromJson(Map<String, dynamic> json) {
@@ -85,6 +86,7 @@ class Extension {
postProcessing: json['post_processing'] != null
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
: null,
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
);
}
@@ -109,6 +111,7 @@ class Extension {
URLHandler? urlHandler,
TrackMatching? trackMatching,
PostProcessing? postProcessing,
Map<String, dynamic>? capabilities,
}) {
return Extension(
id: id ?? this.id,
@@ -131,6 +134,7 @@ class Extension {
urlHandler: urlHandler ?? this.urlHandler,
trackMatching: trackMatching ?? this.trackMatching,
postProcessing: postProcessing ?? this.postProcessing,
capabilities: capabilities ?? this.capabilities,
);
}
@@ -138,9 +142,30 @@ class Extension {
bool get hasURLHandler => urlHandler?.enabled ?? false;
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasHomeFeed => capabilities['homeFeed'] == 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?,
);
}
}
/// Custom search behavior configuration
class SearchBehavior {
final bool enabled;
final String? placeholder;
@@ -149,6 +174,7 @@ class SearchBehavior {
final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
final int? thumbnailWidth;
final int? thumbnailHeight;
final List<SearchFilter> filters; // Available search filters (e.g., track, album, artist, playlist)
const SearchBehavior({
required this.enabled,
@@ -158,6 +184,7 @@ class SearchBehavior {
this.thumbnailRatio,
this.thumbnailWidth,
this.thumbnailHeight,
this.filters = const [],
});
factory SearchBehavior.fromJson(Map<String, dynamic> json) {
@@ -169,11 +196,12 @@ class SearchBehavior {
thumbnailRatio: json['thumbnailRatio'] as String?,
thumbnailWidth: json['thumbnailWidth'] as int?,
thumbnailHeight: json['thumbnailHeight'] as int?,
filters: (json['filters'] as List<dynamic>?)
?.map((f) => SearchFilter.fromJson(f as Map<String, dynamic>))
.toList() ?? [],
);
}
/// Get thumbnail size based on configuration
/// Returns (width, height) tuple
(double, double) getThumbnailSize({double defaultSize = 56}) {
if (thumbnailWidth != null && thumbnailHeight != null) {
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
@@ -191,11 +219,10 @@ class SearchBehavior {
}
}
/// Custom track matching configuration
class TrackMatching {
final bool customMatching;
final String? strategy; // "isrc", "name", "duration", "custom"
final int durationTolerance; // in seconds
final String? strategy;
final int durationTolerance;
const TrackMatching({
required this.customMatching,
@@ -212,7 +239,6 @@ class TrackMatching {
}
}
/// Post-processing configuration
class PostProcessing {
final bool enabled;
final List<PostProcessingHook> hooks;
@@ -262,7 +288,6 @@ class URLHandler {
}
}
/// A post-processing hook
class PostProcessingHook {
final String id;
final String name;
@@ -289,12 +314,11 @@ class PostProcessingHook {
}
}
/// Represents a quality option for download providers
class QualityOption {
final String id;
final String label;
final String? description;
final List<QualitySpecificSetting> settings; // Quality-specific settings
final List<QualitySpecificSetting> settings;
const QualityOption({
required this.id,
@@ -315,14 +339,13 @@ class QualityOption {
}
}
/// Represents a setting that's specific to a quality option
class QualitySpecificSetting {
final String key;
final String label;
final String type; // 'string', 'number', 'boolean', 'select'
final String type;
final dynamic defaultValue;
final String? description;
final List<String>? options; // For select type
final List<String>? options;
final bool required;
final bool secret;
@@ -351,15 +374,15 @@ class QualitySpecificSetting {
}
}
/// Represents a setting field for an extension
class ExtensionSetting {
final String key;
final String label;
final String type; // 'string', 'number', 'boolean', 'select'
final String type;
final dynamic defaultValue;
final String? description;
final List<String>? options; // For select type
final List<String>? options;
final bool required;
final String? action;
const ExtensionSetting({
required this.key,
@@ -369,6 +392,7 @@ class ExtensionSetting {
this.description,
this.options,
this.required = false,
this.action,
});
factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
@@ -380,11 +404,11 @@ class ExtensionSetting {
description: json['description'] as String?,
options: (json['options'] as List<dynamic>?)?.cast<String>(),
required: json['required'] as bool? ?? false,
action: json['action'] as String?,
);
}
}
/// State for extension management
class ExtensionState {
final List<Extension> extensions;
final List<String> providerPriority;
@@ -422,7 +446,6 @@ class ExtensionState {
}
/// Provider for managing extensions
class ExtensionNotifier extends Notifier<ExtensionState> {
@override
ExtensionState build() {
@@ -448,7 +471,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Load all extensions from directory
Future<void> loadExtensions(String dirPath) async {
state = state.copyWith(isLoading: true, error: null);
@@ -483,12 +505,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Clear any error state
void clearError() {
state = state.copyWith(error: null);
}
/// Install extension from file (auto-upgrades if already installed with newer version)
Future<bool> installExtension(String filePath) async {
state = state.copyWith(isLoading: true, error: null);
@@ -505,8 +525,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Check if a package file is an upgrade for an existing extension
/// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed}
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
try {
return await PlatformBridge.checkExtensionUpgrade(filePath);
@@ -516,7 +534,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Upgrade an existing extension from a new package file
Future<bool> upgradeExtension(String filePath) async {
state = state.copyWith(isLoading: true, error: null);
@@ -550,7 +567,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Enable or disable an extension
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
try {
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
@@ -597,7 +613,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Update settings for an extension
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
try {
await PlatformBridge.setExtensionSettings(extensionId, settings);
@@ -618,7 +633,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Set provider priority order
Future<void> setProviderPriority(List<String> priority) async {
try {
await PlatformBridge.setProviderPriority(priority);
@@ -640,7 +654,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Set metadata provider priority order
Future<void> setMetadataProviderPriority(List<String> priority) async {
try {
await PlatformBridge.setMetadataProviderPriority(priority);
@@ -662,7 +675,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Get extension by ID
Extension? getExtension(String extensionId) {
try {
return state.extensions.firstWhere((ext) => ext.id == extensionId);
@@ -676,7 +688,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return state.extensions.where((ext) => ext.enabled).toList();
}
/// Get all download providers (built-in + extensions)
List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz', 'amazon'];
for (final ext in state.extensions) {
@@ -697,7 +708,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
return providers;
}
/// Get all extensions that provide custom search
List<Extension> get searchProviders {
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
}
+46 -15
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _recentAccessKey = 'recent_access_history';
const _hiddenDownloadsKey = 'hidden_downloads_in_recents';
const _maxRecentItems = 20;
/// Types of items that can be accessed
@@ -75,19 +76,23 @@ class RecentAccessItem {
/// State for recent access history
class RecentAccessState {
final List<RecentAccessItem> items;
final Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
final bool isLoaded;
const RecentAccessState({
this.items = const [],
this.hiddenDownloadIds = const {},
this.isLoaded = false,
});
RecentAccessState copyWith({
List<RecentAccessItem>? items,
Set<String>? hiddenDownloadIds,
bool? isLoaded,
}) {
return RecentAccessState(
items: items ?? this.items,
hiddenDownloadIds: hiddenDownloadIds ?? this.hiddenDownloadIds,
isLoaded: isLoaded ?? this.isLoaded,
);
}
@@ -95,6 +100,8 @@ class RecentAccessState {
/// Provider for managing recent access history
class RecentAccessNotifier extends Notifier<RecentAccessState> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override
RecentAccessState build() {
_loadHistory();
@@ -102,29 +109,42 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
Future<void> _loadHistory() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
final json = prefs.getString(_recentAccessKey);
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
List<RecentAccessItem> items = [];
Set<String> hiddenIds = {};
if (json != null) {
try {
final List<dynamic> decoded = jsonDecode(json);
final items = decoded
items = decoded
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(items: items, isLoaded: true);
} catch (e) {
state = state.copyWith(isLoaded: true);
} catch (_) {
// Ignore JSON parse errors, use empty list
}
} else {
state = state.copyWith(isLoaded: true);
}
if (hiddenJson != null) {
hiddenIds = hiddenJson.toSet();
}
state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true);
}
Future<void> _saveHistory() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
await prefs.setString(_recentAccessKey, json);
}
Future<void> _saveHiddenDownloads() async {
final prefs = await _prefs;
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
}
/// Record an access to an artist
void recordArtistAccess({
required String id,
@@ -200,9 +220,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
void _recordAccess(RecentAccessItem item) {
// ignore: avoid_print
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
.toList();
@@ -215,9 +232,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
state = state.copyWith(items: updatedItems);
_saveHistory();
// ignore: avoid_print
print('[RecentAccess] Total items now: ${updatedItems.length}');
}
/// Remove a specific item from history
@@ -229,14 +243,31 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
_saveHistory();
}
/// Hide a download item from recents (without deleting the actual download)
void hideDownloadFromRecents(String downloadId) {
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
state = state.copyWith(hiddenDownloadIds: updatedHidden);
_saveHiddenDownloads();
}
/// Check if a download is hidden from recents
bool isDownloadHidden(String downloadId) {
return state.hiddenDownloadIds.contains(downloadId);
}
/// Clear all history
void clearHistory() {
state = state.copyWith(items: []);
_saveHistory();
}
/// Clear hidden downloads (show all again)
void clearHiddenDownloads() {
state = state.copyWith(hiddenDownloadIds: {});
_saveHiddenDownloads();
}
}
/// Provider instance
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
RecentAccessNotifier.new,
);
+37 -4
View File
@@ -10,6 +10,8 @@ const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 1;
class SettingsNotifier extends Notifier<AppSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override
AppSettings build() {
_loadSettings();
@@ -17,7 +19,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
final json = prefs.getString(_settingsKey);
if (json != null) {
state = AppSettings.fromJson(jsonDecode(json));
@@ -30,7 +32,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
}
/// Run one-time migrations for settings
Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
@@ -47,11 +48,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
Future<void> _saveSettings() async {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
}
/// Apply current Spotify credentials to Go backend
Future<void> _applySpotifyCredentials() async {
if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
@@ -92,6 +92,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setLyricsMode(String mode) {
if (mode == 'embed' || mode == 'external' || mode == 'both') {
state = state.copyWith(lyricsMode: mode);
_saveSettings();
}
}
void setMaxQualityCover(bool enabled) {
state = state.copyWith(maxQualityCover: enabled);
_saveSettings();
@@ -223,6 +230,32 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(locale: locale);
_saveSettings();
}
void setEnableLossyOption(bool enabled) {
state = state.copyWith(enableLossyOption: enabled);
// If Lossy is disabled and current quality is LOSSY, reset to LOSSLESS
if (!enabled && state.audioQuality == 'LOSSY') {
state = state.copyWith(audioQuality: 'LOSSLESS');
}
_saveSettings();
}
void setLossyFormat(String format) {
state = state.copyWith(lossyFormat: format);
_saveSettings();
}
void setLossyBitrate(String bitrate) {
// Extract format from bitrate (e.g., 'mp3_320' -> 'mp3')
final format = bitrate.split('_').first;
state = state.copyWith(lossyBitrate: bitrate, lossyFormat: format);
_saveSettings();
}
void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+3 -9
View File
@@ -5,12 +5,13 @@ import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('StoreProvider');
final RegExp _leadingVersionPrefix = RegExp(r'^v');
/// Compare two semantic version strings
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.');
final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.');
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
@@ -52,7 +53,6 @@ class StoreCategory {
}
}
/// Represents an extension in the store
class StoreExtension {
final String id;
final String name;
@@ -118,7 +118,6 @@ class StoreExtension {
}
}
/// State for extension store
class StoreState {
final List<StoreExtension> extensions;
final String? selectedCategory;
@@ -200,7 +199,6 @@ class StoreNotifier extends Notifier<StoreState> {
return const StoreState();
}
/// Initialize the store
Future<void> initialize(String cacheDir) async {
if (state.isInitialized) return;
@@ -234,7 +232,6 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
/// Set category filter
void setCategory(String? category) {
if (category == null) {
state = state.copyWith(clearCategory: true);
@@ -248,7 +245,6 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(searchQuery: query);
}
/// Clear search
void clearSearch() {
state = state.copyWith(searchQuery: '', clearCategory: true);
}
@@ -279,7 +275,6 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
/// Update an installed extension
Future<bool> updateExtension(String extensionId, String tempDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
@@ -305,7 +300,6 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
/// Clear error
void clearError() {
state = state.copyWith(clearError: true);
}
+4 -3
View File
@@ -10,6 +10,8 @@ final themeProvider = NotifierProvider<ThemeNotifier, ThemeSettings>(() {
/// Notifier for managing theme settings with persistence
class ThemeNotifier extends Notifier<ThemeSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override
ThemeSettings build() {
// Load settings asynchronously on first access
@@ -20,7 +22,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
/// Load theme settings from SharedPreferences
Future<void> _loadFromStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
final modeString = prefs.getString(kThemeModeKey);
final useDynamic = prefs.getBool(kUseDynamicColorKey);
final seedColor = prefs.getInt(kSeedColorKey);
@@ -34,14 +36,13 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
);
} catch (e) {
debugPrint('Error loading theme settings: $e');
// Keep default state on error
}
}
/// Save current settings to SharedPreferences
Future<void> _saveToStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await _prefs;
await prefs.setString(kThemeModeKey, state.themeMode.name);
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
await prefs.setInt(kSeedColorKey, state.seedColorValue);
+137 -26
View File
@@ -22,9 +22,12 @@ class TrackState {
final List<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks
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 isShowingRecentAccess; // For recent access mode
final String? searchExtensionId; // Extension ID used for current search results
final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
const TrackState({
this.tracks = const [],
@@ -41,12 +44,15 @@ class TrackState {
this.artistAlbums,
this.artistTopTracks,
this.searchArtists,
this.searchAlbums,
this.searchPlaylists,
this.hasSearchText = false,
this.isShowingRecentAccess = false,
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({
List<Track>? tracks,
@@ -63,9 +69,13 @@ class TrackState {
List<ArtistAlbum>? artistAlbums,
List<Track>? artistTopTracks,
List<SearchArtist>? searchArtists,
List<SearchAlbum>? searchAlbums,
List<SearchPlaylist>? searchPlaylists,
bool? hasSearchText,
bool? isShowingRecentAccess,
String? searchExtensionId,
String? selectedSearchFilter,
bool clearSelectedSearchFilter = false,
}) {
return TrackState(
tracks: tracks ?? this.tracks,
@@ -82,14 +92,16 @@ class TrackState {
artistAlbums: artistAlbums ?? this.artistAlbums,
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
searchArtists: searchArtists ?? this.searchArtists,
searchAlbums: searchAlbums ?? this.searchAlbums,
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
hasSearchText: hasSearchText ?? this.hasSearchText,
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
searchExtensionId: searchExtensionId,
selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter),
);
}
}
/// Represents an album in artist discography
class ArtistAlbum {
final String id;
final String name;
@@ -112,7 +124,6 @@ class ArtistAlbum {
});
}
/// Represents an artist in search results
class SearchArtist {
final String id;
final String name;
@@ -129,8 +140,43 @@ 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> {
/// Request ID to track and cancel outdated requests
int _currentRequestId = 0;
@override
@@ -213,14 +259,8 @@ class TrackNotifier extends Notifier<TrackState> {
Map<String, dynamic> metadata;
try {
// ignore: avoid_print
print('[FetchURL] Fetching $type with Deezer fallback enabled...');
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
// ignore: avoid_print
print('[FetchURL] Metadata fetch success');
} catch (e) {
// ignore: avoid_print
print('[FetchURL] Metadata fetch failed: $e');
rethrow;
}
@@ -263,7 +303,7 @@ class TrackNotifier extends Notifier<TrackState> {
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
state = TrackState(
tracks: [], // No tracks for artist view
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
@@ -277,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;
// 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 {
final settings = ref.read(settingsProvider);
@@ -298,7 +341,7 @@ class TrackNotifier extends Notifier<TrackState> {
final source = metadataSource ?? 'deezer';
_log.i(
'Search started: source=$source, query="$query", useExtensions=$useExtensions',
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
);
Map<String, dynamic> results;
@@ -324,11 +367,11 @@ class TrackNotifier extends Notifier<TrackState> {
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 2, filter: currentFilter);
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums');
} else {
_log.d('Calling Spotify search API...');
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 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');
}
@@ -339,8 +382,9 @@ class TrackNotifier extends Notifier<TrackState> {
final trackList = results['tracks'] as List<dynamic>? ?? [];
final artistList = results['artists'] as List<dynamic>? ?? [];
final albumList = results['albums'] as List<dynamic>? ?? [];
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums');
final tracks = <Track>[];
@@ -382,26 +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(
tracks: tracks,
searchArtists: artists,
searchAlbums: albums,
searchPlaylists: playlists,
isLoading: false,
hasSearchText: state.hasSearchText,
selectedSearchFilter: currentFilter, // Preserve filter in results
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
_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);
}
}
/// Perform custom search using a specific extension
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
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 {
_log.i('Custom search started: extension=$extensionId, query="$query"');
@@ -429,10 +508,11 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(
tracks: tracks,
searchArtists: [], // Custom search doesn't return artists
searchArtists: [],
isLoading: false,
hasSearchText: state.hasSearchText,
searchExtensionId: extensionId, // Store which extension was used
selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
@@ -484,12 +564,23 @@ class TrackNotifier extends Notifier<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
void setSearchText(bool hasText) {
if (state.hasSearchText == hasText) {
return;
}
state = state.copyWith(hasSearchText: hasText);
}
/// Set recent access mode state
void setShowingRecentAccess(bool showing) {
state = state.copyWith(isShowingRecentAccess: showing);
}
@@ -579,8 +670,28 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
/// Pre-warm track ID cache for faster downloads
/// Runs in background, doesn't block UI
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) {
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
if (tracksWithIsrc.isEmpty) return;

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