Compare commits

...

370 Commits

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

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

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

- Also fixes playlists with >25 tracks

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

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

- Add _embedMetadataToOpus() in download queue provider

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

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

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

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

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

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

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

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

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

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

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

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

- Also fixes playlists with >25 tracks

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

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

- Add _embedMetadataToOpus() in download queue provider

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

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

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

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

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

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

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

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

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

Uses FlutterShellArgs --enable-impeller=false which is the only
reliable method since AndroidManifest meta-data is broken in
Flutter 3.27+ (flutter/flutter#160595)
2026-01-24 09:02:32 +07:00
zarzet 1aa12c5857 feat: add search filter bar for extension custom search
- Add SearchFilter struct in Go backend and Dart
- Add filters array to SearchBehaviorConfig manifest
- Add selectedSearchFilter state to TrackProvider
- Add filter bar UI with FilterChips below search bar
- Filter bar only shows when search results exist or loading
- Preserve selectedSearchFilter during customSearch loading
- Pass filter option to extension customSearch
2026-01-24 08:50:41 +07:00
Amonoman 80707fc438 Update about_page.dart
i changed it becouse "Max" is not my username
2026-01-23 20:34:43 +01:00
Zarz Eleutherius ff121dfeb8 New translations app_en.arb (Indonesian) 2026-01-23 11:53:28 +07:00
zarzet c3aa6a441b fix: update Telegram community link in About page 2026-01-22 07:58:55 +07:00
Zarz Eleutherius 496d32e35b New translations app_en.arb (Turkish) 2026-01-22 07:34:38 +07:00
Zarz Eleutherius 291fa58757 New translations app_en.arb (Hindi) 2026-01-22 07:34:37 +07:00
Zarz Eleutherius eddbc2f986 New translations app_en.arb (Indonesian) 2026-01-22 07:34:36 +07:00
Zarz Eleutherius 81b8281d2c New translations app_en.arb (Chinese Traditional) 2026-01-22 07:34:35 +07:00
Zarz Eleutherius 57f87d9a4c New translations app_en.arb (Chinese Simplified) 2026-01-22 07:34:33 +07:00
Zarz Eleutherius c9d0c57d86 New translations app_en.arb (Russian) 2026-01-22 07:34:32 +07:00
Zarz Eleutherius 54ab5a9243 New translations app_en.arb (Portuguese) 2026-01-22 07:34:31 +07:00
Zarz Eleutherius 17b6b27cd7 New translations app_en.arb (Dutch) 2026-01-22 07:34:30 +07:00
Zarz Eleutherius ed131ca1fd New translations app_en.arb (Korean) 2026-01-22 07:34:29 +07:00
Zarz Eleutherius 190d65cdee New translations app_en.arb (Japanese) 2026-01-22 07:34:28 +07:00
Zarz Eleutherius dbf2e337f0 New translations app_en.arb (German) 2026-01-22 07:34:27 +07:00
Zarz Eleutherius 12e76bed4f New translations app_en.arb (Spanish) 2026-01-22 07:34:26 +07:00
Zarz Eleutherius e00db80dae New translations app_en.arb (French) 2026-01-22 07:34:24 +07:00
Zarz Eleutherius 5de0aa8145 Update source file app_en.arb 2026-01-22 07:34:20 +07:00
zarzet 91ffb25027 chore: bump version to 26.2.1+65 (new year.month.day format) 2026-01-22 07:06:15 +07:00
zarzet 6bcbdfedf0 Merge branch 'main' into dev 2026-01-22 07:04:03 +07:00
zarzet 3f42128cb9 fix: update Telegram community link and VirusTotal hash for v3.2.1 2026-01-22 04:50:46 +07:00
zarzet ccb8f98df5 fix: use --data-urlencode for Telegram message to handle special chars (+, &) 2026-01-22 04:26:02 +07:00
zarzet 591a597333 Merge branch 'dev'
# Conflicts:
#	.github/workflows/release.yml
#	README.md
2026-01-22 04:01:24 +07:00
zarzet 6388f3a5b8 perf: optimize providers, caching, and reduce rebuilds
- Cache SharedPreferences.getInstance() in providers (settings, theme, recent_access)
- Pre-compute download counts in queue provider to avoid repeated filtering
- Add identical() caching for RecentAccessView in HomeTab
- Use selective watching for exploreProvider (sections, greeting, isLoading only)
- Move isYTMusicQuickPicks computation to ExploreSection.fromJson()
- Hoist static RegExp patterns to avoid repeated compilation
- Use batch operations for iOS path migration in history_database
- Replace containsKey+lookup with single lookup in palette_service
- Pre-compute lowercase strings outside filter loops in logger
- Fix _isLoaded race condition in DownloadHistoryNotifier
2026-01-22 03:56:47 +07:00
Zarz Eleutherius 22f52f4af2 New translations app_en.arb (Turkish) 2026-01-22 02:27:30 +07:00
Zarz Eleutherius ceaaff8c9b New translations app_en.arb (Hindi) 2026-01-22 02:27:29 +07:00
Zarz Eleutherius a318495046 New translations app_en.arb (Indonesian) 2026-01-22 02:27:27 +07:00
Zarz Eleutherius 8ffc6d3821 New translations app_en.arb (Chinese Traditional) 2026-01-22 02:27:26 +07:00
Zarz Eleutherius 2036e46da0 New translations app_en.arb (Chinese Simplified) 2026-01-22 02:27:25 +07:00
Zarz Eleutherius b82000e87c New translations app_en.arb (Russian) 2026-01-22 02:27:24 +07:00
Zarz Eleutherius 144906fd8f New translations app_en.arb (Portuguese) 2026-01-22 02:27:23 +07:00
Zarz Eleutherius 8a109e9013 New translations app_en.arb (Dutch) 2026-01-22 02:27:21 +07:00
Zarz Eleutherius ba05f6b470 New translations app_en.arb (Korean) 2026-01-22 02:27:20 +07:00
Zarz Eleutherius 2f80ae7e84 New translations app_en.arb (Japanese) 2026-01-22 02:27:19 +07:00
Zarz Eleutherius e248fef130 New translations app_en.arb (German) 2026-01-22 02:27:18 +07:00
Zarz Eleutherius 174724ddd3 New translations app_en.arb (Spanish) 2026-01-22 02:27:17 +07:00
Zarz Eleutherius 730945d892 New translations app_en.arb (French) 2026-01-22 02:27:15 +07:00
Zarz Eleutherius 4abdce8c58 Update source file app_en.arb 2026-01-22 02:27:13 +07:00
zarzet 55b75dc48d chore: bump version to 3.2.1+64 2026-01-22 02:17:47 +07:00
zarzet f6cea1a683 feat: v3.2.1 - lyrics improvements, pause/resume, folder options
- Add instrumental track detection (shows 'Instrumental track' instead of 'not available')
- Add embed lyrics button in Track Info (preserves synced timestamps)
- Add pause/resume button next to 'Downloading' header in History
- Add Artist/Album + Singles folder structure option
- Fix multi-artist lyrics search (try primary artist first)
- Fix lyrics display stripping metadata tags ([ti:], [ar:], [by:])
- Skip lyrics embedding for instrumental tracks during download
2026-01-22 02:15:43 +07:00
zarzet 8d205600b8 fix: iOS path migration, local greeting timezone, ICU plural warnings
- iOS: Auto-migrate file paths when container UUID changes after app update
- Greeting: Use device local time instead of extension response
- i18n: Fix 16 ICU plural syntax warnings in Spanish and Portuguese
2026-01-22 00:48:45 +07:00
zarzet aa35f60fad fix: fallback to index+1 for Deezer track position when API returns 0 2026-01-21 16:33:30 +07:00
zarzet b627ae1874 fix: handle CRLF in changelog extraction for Telegram 2026-01-21 16:23:19 +07:00
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
Zarz Eleutherius 0d98ada479 New translations app_en.arb (Turkish) 2026-01-21 02:22:48 +07:00
Zarz Eleutherius 5d4fc10ab7 New translations app_en.arb (Hindi) 2026-01-21 02:22:46 +07:00
Zarz Eleutherius e37dfeb080 New translations app_en.arb (Indonesian) 2026-01-21 02:22:45 +07:00
Zarz Eleutherius eddae2a9dd New translations app_en.arb (Chinese Traditional) 2026-01-21 02:22:44 +07:00
Zarz Eleutherius 6bd7eec615 New translations app_en.arb (Chinese Simplified) 2026-01-21 02:22:43 +07:00
Zarz Eleutherius b240e91290 New translations app_en.arb (Russian) 2026-01-21 02:22:42 +07:00
Zarz Eleutherius 4e0149df29 New translations app_en.arb (Portuguese) 2026-01-21 02:22:41 +07:00
Zarz Eleutherius 065872e686 New translations app_en.arb (Dutch) 2026-01-21 02:22:39 +07:00
Zarz Eleutherius 7ab0f5b7c8 New translations app_en.arb (Korean) 2026-01-21 02:22:38 +07:00
Zarz Eleutherius fd31682242 New translations app_en.arb (Japanese) 2026-01-21 02:22:37 +07:00
Zarz Eleutherius 56c8b62fcf New translations app_en.arb (German) 2026-01-21 02:22:36 +07:00
Zarz Eleutherius c3f879346a New translations app_en.arb (Spanish) 2026-01-21 02:22:35 +07:00
Zarz Eleutherius 6da65ed033 New translations app_en.arb (French) 2026-01-21 02:22:34 +07:00
Zarz Eleutherius 553c6b6c4a Update source file app_en.arb 2026-01-21 02:22:31 +07:00
zarzet ac5f74a48f feat: convert GitHub Markdown to Telegram format in release notification 2026-01-20 10:12:18 +07:00
zarzet 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
Zarz Eleutherius a32487ad88 New translations app_en.arb (Hindi) 2026-01-20 02:16:58 +07:00
Zarz Eleutherius bd4946db37 New translations app_en.arb (Indonesian) 2026-01-20 02:16:57 +07:00
Zarz Eleutherius 69f143dd9d New translations app_en.arb (Chinese Traditional) 2026-01-20 02:16:56 +07:00
Zarz Eleutherius 15408bfa1c New translations app_en.arb (Chinese Simplified) 2026-01-20 02:16:55 +07:00
Zarz Eleutherius edc715021d New translations app_en.arb (Russian) 2026-01-20 02:16:54 +07:00
Zarz Eleutherius 392472b027 New translations app_en.arb (Portuguese) 2026-01-20 02:16:53 +07:00
Zarz Eleutherius 69741fa47c New translations app_en.arb (Dutch) 2026-01-20 02:16:52 +07:00
Zarz Eleutherius 484720bcda New translations app_en.arb (Korean) 2026-01-20 02:16:51 +07:00
Zarz Eleutherius f3cc51fb06 New translations app_en.arb (Japanese) 2026-01-20 02:16:50 +07:00
Zarz Eleutherius 452ea7084a New translations app_en.arb (German) 2026-01-20 02:16:49 +07:00
Zarz Eleutherius bba059fc44 New translations app_en.arb (Spanish) 2026-01-20 02:16:48 +07:00
Zarz Eleutherius 3f75cace2b New translations app_en.arb (French) 2026-01-20 02:16:47 +07:00
zarzet 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 0ccda8db58 fix: locale format and translation updates 2026-01-18 03:27:43 +07:00
zarzet 556c0e1db2 Merge dev into main 2026-01-18 03:21:02 +07:00
zarzet 6d7b89b881 v3.1.1: Lyrics caching, duration matching, Deezer cover upgrade, live extension search, Russian language, fix race conditions and scroll exceptions 2026-01-18 03:15:20 +07:00
Zarz Eleutherius 47777b4343 Merge pull request #65 from zarzet/l10n_dev
New Crowdin updates
2026-01-18 01:46:48 +07:00
Zarz Eleutherius 2eb1d2a65d New translations app_en.arb (Russian) 2026-01-18 01:45:39 +07:00
Zarz Eleutherius ce057c6473 New translations app_en.arb (Japanese) 2026-01-18 01:45:36 +07:00
Zarz Eleutherius 46cfe8b632 Merge pull request #58 from zarzet/l10n_dev
New Crowdin updates
2026-01-17 22:01:48 +07:00
zarzet 2e5eff6e3d chore: add cursor files to gitignore 2026-01-17 10:10:56 +07:00
zarzet dd506efeb6 chore: remove .cursorignore from tracking 2026-01-17 10:10:20 +07:00
zarzet 9897d3102e Merge branch 'dev' into main 2026-01-17 10:06:38 +07:00
zarzet 8d92d22fda refactor: more code cleanup 2026-01-17 10:04:21 +07:00
zarzet b99764b1ad refactor: cleanup unused code and imports 2026-01-17 09:50:00 +07:00
zarzet 621582cf11 refactor: additional code cleanup 2026-01-17 09:36:05 +07:00
zarzet b96233f90b refactor: code cleanup and improvements 2026-01-17 09:07:29 +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 be9444c76b fix: revert material_color_utilities to ^0.11.1 (pinned by Flutter SDK) 2026-01-17 05:41:33 +07:00
Zarz Eleutherius 65e21a421d New translations app_en.arb (Hindi) 2026-01-17 05:23:55 +07:00
Zarz Eleutherius 87b33dda7e New translations app_en.arb (Indonesian) 2026-01-17 05:23:54 +07:00
Zarz Eleutherius 2f097c8f6c New translations app_en.arb (Chinese Traditional) 2026-01-17 05:23:53 +07:00
Zarz Eleutherius 8cbdea1417 New translations app_en.arb (Chinese Simplified) 2026-01-17 05:23:52 +07:00
Zarz Eleutherius 48bdd154f6 New translations app_en.arb (Russian) 2026-01-17 05:23:51 +07:00
Zarz Eleutherius ae0e157c34 New translations app_en.arb (Portuguese) 2026-01-17 05:23:50 +07:00
Zarz Eleutherius 53fcdd9a47 New translations app_en.arb (Dutch) 2026-01-17 05:23:49 +07:00
Zarz Eleutherius 3d6be3bf92 New translations app_en.arb (Korean) 2026-01-17 05:23:48 +07:00
Zarz Eleutherius 2d7fba3f52 New translations app_en.arb (Japanese) 2026-01-17 05:23:47 +07:00
Zarz Eleutherius e02d8ff2cd New translations app_en.arb (German) 2026-01-17 05:23:46 +07:00
Zarz Eleutherius f8cee25958 New translations app_en.arb (Spanish) 2026-01-17 05:23:45 +07:00
Zarz Eleutherius 99c133aae1 New translations app_en.arb (French) 2026-01-17 05:23:45 +07:00
zarzet cedb32904e fix(ios): add localization support for iOS build
- Add flutter_localizations and intl to pubspec_ios.yaml

- Add generate: true flag for l10n code generation

- Add CFBundleLocalizations to Info.plist with all supported languages

- Update http to ^1.6.0 and material_color_utilities to ^0.13.0

- Update file_picker to ^10.3.8
2026-01-17 05:20:04 +07:00
zarzet e73f932083 fix: update Crowdin config for Chinese locales and add missing l10n keys
- Change crowdin.yml to use %locale_with_underscore% for proper zh_CN/zh_TW handling
- Add sectionLanguage, appearanceLanguage, appearanceLanguageSubtitle to app_en.arb
- Add app_zh_CN.arb for Simplified Chinese (Crowdin target)
- Update .gitignore to exclude log files and tool/ folder
- Regenerate localization dart files
2026-01-17 05:02:57 +07:00
zarzet 4645d3ac8b fix: correct @@locale values to match filenames (es-ES→es, pt-PT→pt, zh-TW→zh) 2026-01-17 04:51:21 +07:00
Zarz Eleutherius 1cdf8b7f23 Merge pull request #57 from zarzet/l10n_dev
New Crowdin updates
2026-01-17 04:34:21 +07:00
zarzet 1e18f53e6a Merge dev into l10n_dev - resolve conflicts by keeping dev version 2026-01-17 04:33:34 +07:00
zarzet fc8cfb05d0 feat: add recent access history, artist screen redesign, and extension improvements
Recent Access History:
- Quick access to recently visited artists, albums, playlists, and tracks
- Tap search bar to show recent access list
- Stays visible after keyboard dismiss, exit with back button
- Persists across app restarts (SharedPreferences)
- X button to remove items, Clear All button for all

Artist Screen Redesign:
- Full-width header image with gradient overlay
- Monthly listeners display with compact notation
- Popular section with top 5 tracks and download status
- Extension artists skip Spotify/Deezer fetch (no rate limit errors)

Go Backend:
- GetArtistWithExtensionJSON now returns top_tracks, header_image, listeners

Bug Fixes:
- Search bar unfocus when tapping outside
- Keyboard not appearing on Settings navigation return
- Recent access artist navigation uses correct screen for extensions
- Extension artist screen correctly parses and forwards top tracks

Localization:
- Added recentPlaylistInfo, errorGeneric strings
- Multi-language support via Crowdin

Extensions:
- YT Music: v1.5.0 (top_tracks in getArtist)
- Spotify Web: v1.6.0
2026-01-17 04:29:39 +07:00
Zarz Eleutherius fc0c0571fe New translations app_en.arb (Hindi) 2026-01-16 07:24:21 +07:00
Zarz Eleutherius e6ca29e199 New translations app_en.arb (Indonesian) 2026-01-16 07:24:20 +07:00
Zarz Eleutherius 7413a8a698 New translations app_en.arb (Chinese Traditional) 2026-01-16 07:24:19 +07:00
Zarz Eleutherius 205032e094 New translations app_en.arb (Chinese Simplified) 2026-01-16 07:24:18 +07:00
Zarz Eleutherius 9c6f438e22 New translations app_en.arb (Russian) 2026-01-16 07:24:17 +07:00
Zarz Eleutherius 4f2587554a New translations app_en.arb (Portuguese) 2026-01-16 07:24:16 +07:00
Zarz Eleutherius 369fdd84bf New translations app_en.arb (Dutch) 2026-01-16 07:24:15 +07:00
Zarz Eleutherius 5c3b668e92 New translations app_en.arb (Korean) 2026-01-16 07:24:14 +07:00
Zarz Eleutherius 141db45051 New translations app_en.arb (Japanese) 2026-01-16 07:24:13 +07:00
Zarz Eleutherius 8f9bc8f058 New translations app_en.arb (German) 2026-01-16 07:24:11 +07:00
Zarz Eleutherius be372604fe New translations app_en.arb (Spanish) 2026-01-16 07:24:10 +07:00
Zarz Eleutherius 6c25fc6a8d New translations app_en.arb (French) 2026-01-16 07:24:09 +07:00
Zarz Eleutherius 2eef021587 Update source file app_en.arb 2026-01-16 07:24:03 +07:00
zarzet f4fe74f972 docs: add Crowdin translation badge to README 2026-01-16 07:08:49 +07:00
zarzet 9eac6e6e56 docs: add Crowdin translation badge to README 2026-01-16 07:07:46 +07:00
zarzet e5c310f455 fix: update crowdin.yml to use two_letters_code for locale format
- Change from %locale% (id-ID) to %two_letters_code% (id)
- Matches Flutter l10n expected filename format
2026-01-16 06:39:16 +07:00
zarzet d8f73dfa56 feat: add support for 13 languages with improved language selector
- Rename Crowdin ARB files from locale-REGION to locale format
- Fix @@locale values to match filenames
- Update language selector to bottom sheet picker (supports 13 languages)
- Supported: English, Indonesian, German, Spanish, French, Hindi,
  Japanese, Korean, Dutch, Portuguese, Russian, Chinese (Simplified/Traditional)
- Remove duplicate app_id-ID.arb (keep app_id.arb)
2026-01-16 06:38:52 +07:00
zarzet f128d0caf0 Merge branch 'dev' of https://github.com/zarzet/SpotiFLAC-Mobile into dev 2026-01-16 06:35:44 +07:00
zarzet aa499ceba2 refactor: use consistent ViewModeChip for language selector
- Remove duplicate _LanguageChip widget
- Reuse _ViewModeChip for Material Design 3 consistency
- Same font size (12), padding, and styling as other selectors
2026-01-16 06:28:26 +07:00
zarzet 01306afc2d feat: add language selector in Appearance settings
- Add locale field to AppSettings model with 'system' default
- Add Language section in Appearance settings page
- Support System Default, English, and Indonesian options
- App language changes take effect immediately
- Add localization strings for language selector
2026-01-16 06:25:24 +07:00
Zarz Eleutherius 9a3cd0273b Merge pull request #56 from zarzet/l10n_dev
New Crowdin updates
2026-01-16 06:25:02 +07:00
Zarz Eleutherius ac25683f33 New translations app_en.arb (Hindi) 2026-01-16 06:22:51 +07:00
Zarz Eleutherius 624b2112d8 New translations app_en.arb (Indonesian) 2026-01-16 06:22:50 +07:00
Zarz Eleutherius 8bd34dc87e New translations app_en.arb (Chinese Traditional) 2026-01-16 06:22:49 +07:00
Zarz Eleutherius 948779bcfc New translations app_en.arb (Chinese Simplified) 2026-01-16 06:22:48 +07:00
Zarz Eleutherius a74b3a19f7 New translations app_en.arb (Russian) 2026-01-16 06:22:47 +07:00
Zarz Eleutherius 931d9fbf61 New translations app_en.arb (Portuguese) 2026-01-16 06:22:46 +07:00
Zarz Eleutherius a8c76004db New translations app_en.arb (Dutch) 2026-01-16 06:22:45 +07:00
Zarz Eleutherius 0df4596f79 New translations app_en.arb (Korean) 2026-01-16 06:22:44 +07:00
Zarz Eleutherius cf549df049 New translations app_en.arb (Japanese) 2026-01-16 06:22:43 +07:00
Zarz Eleutherius bd3783154b New translations app_en.arb (German) 2026-01-16 06:22:42 +07:00
Zarz Eleutherius 6919408905 New translations app_en.arb (Spanish) 2026-01-16 06:22:41 +07:00
Zarz Eleutherius f4c08a5981 New translations app_en.arb (French) 2026-01-16 06:22:39 +07:00
zarzet 7fff55da96 chore: add crowdin.yml for translation sync 2026-01-16 06:16:28 +07:00
zarzet 3c4dbd1a80 docs: add @key metadata descriptions for Crowdin translators
- Add description field to all 400+ localization strings
- Mark brand names and technical terms as DO NOT TRANSLATE
- Add placeholder descriptions for parameterized strings
- Helps translators understand context for each string
2026-01-16 05:58:36 +07:00
zarzet f26af38c1e feat: add multilanguage support (i18n) for English and Indonesian
- Add flutter_localizations and intl dependencies
- Create l10n.yaml configuration and ARB files (app_en.arb, app_id.arb)
- Add L10n extension for easy context.l10n access
- Localize all active screens:
  - setup_screen, track_metadata_screen, log_screen
  - download_settings_page, options_settings_page, appearance_settings_page
  - extensions_page, extension_detail_page, extension_details_screen
  - about_page, provider_priority_page, metadata_provider_priority_page
  - home_tab, queue_tab, store_tab, main_shell
  - album_screen, artist_screen, playlist_screen
  - downloaded_album_screen, queue_screen
- Localize widgets: update_dialog, download_service_picker
- Technical terms (FLAC, API, Spotify, Tidal, Qobuz, etc.) are NOT translated
- ~900+ localized strings in English, ~660+ in Indonesian
2026-01-16 05:50:11 +07:00
zarzet 7c6705c75c merge: main into dev - resolved conflicts for mime utils, version, gitignore 2026-01-16 03:47:45 +07:00
zarzet b193bc0b8f feat: download cancellation, duplicate detection, progress tracking improvements 2026-01-16 03:46:31 +07:00
zarzet 1a90887465 release: v3.1.0 - fix Separate Singles, extension metadata, YTMusic parsing
Fixes:
- Fix Separate Singles not working (#54) - albumType not extracted from Deezer API
- Fix extension artist/album metadata missing provider IDs and cover URLs
- Fix YTMusic extension not extracting album name and duration from search
- Fix extension collection screens setState after dispose
- Fix search source chips referencing removed badge props

Changes:
- Deezer convertTrack now includes album_type from record_type
- Track creation preserves albumType and source throughout download flow
- Go exports include provider_id in album/artist responses
- Version bump to 3.1.0+59
2026-01-15 18:54:01 +07:00
zarzet 82440affac feat: add home tab enhancements, download queue improvements, and platform bridge updates 2026-01-15 04:31:33 +07:00
zarzet 6d2f75c5dc docs: update screenshots and VirusTotal link for v3.0.0 2026-01-14 21:42:16 +07:00
153 changed files with 103652 additions and 7556 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 - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.21" go-version: "1.25"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds # Cache Gradle for faster builds
@@ -174,7 +174,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.21" go-version: "1.25"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache CocoaPods # Cache CocoaPods
@@ -194,7 +194,7 @@ jobs:
working-directory: go_backend working-directory: go_backend
run: | run: |
mkdir -p ../ios/Frameworks mkdir -p ../ios/Frameworks
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework . gomobile bind -target=ios -tags ios -o ../ios/Frameworks/Gobackend.xcframework .
env: env:
CGO_ENABLED: 1 CGO_ENABLED: 1
@@ -249,23 +249,6 @@ jobs:
channel: "stable" channel: "stable"
cache: true cache: true
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
- name: Use iOS pubspec with FFmpeg plugin
run: |
cp pubspec.yaml pubspec_android_backup.yaml
cp pubspec_ios.yaml pubspec.yaml
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
# Swap FFmpeg service for iOS
- name: Use iOS FFmpeg service
run: |
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
# Update class name in the swapped file
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
echo "Swapped to iOS FFmpeg service"
- name: Get Flutter dependencies - name: Get Flutter dependencies
run: flutter pub get run: flutter pub get
@@ -412,3 +395,135 @@ jobs:
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }} prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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!"
+14
View File
@@ -6,6 +6,8 @@ Thumbs.db
.idea/ .idea/
.vscode/ .vscode/
*.iml *.iml
.cursorignore
.cursorrules
# Kiro specs (development only) # Kiro specs (development only)
.kiro/ .kiro/
@@ -59,3 +61,15 @@ extension/
# Agent instructions # Agent instructions
AGENTS.md AGENTS.md
# Temp/misc
nul
# Log files
*.log
hs_err_*.log
flutter_*.log
# Development tools
tool/
.claude/settings.local.json
+463 -1522
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! 💚
+58 -20
View File
@@ -1,11 +1,12 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/) [![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"> <div align="center">
<img src="icon.png" width="128" /> <img src="icon.png" width="128" />
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required. Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white) ![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white) ![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
@@ -23,22 +24,14 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
<img src="assets/images/4.jpg?v=2" width="200" /> <img src="assets/images/4.jpg?v=2" width="200" />
</p> </p>
## Metadata Source ## Search Source
SpotiFLAC supports two metadata sources for searching tracks: SpotiFLAC supports multiple search sources for finding music metadata:
| Source | Pros | Cons | | Source | Setup |
|--------|------|------| |--------|-------|
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog | | **Deezer** (Default) | No setup required |
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting | | **Extensions** | Install additional search providers from the Store |
### Using Spotify
To use Spotify as your search source without hitting rate limits:
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
2. Create an app to get your Client ID and Client Secret
3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
4. Enter your Client ID and Secret
5. Change **Search Source** to Spotify
## Extensions ## Extensions
@@ -57,15 +50,55 @@ Want to create your own extension? Check out the [Extension Development Guide](h
## Other project ## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC) ### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
## 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"?**
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
**Q: Why are some tracks downloading in lower quality?**
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
**Q: Can I download playlists?**
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
**Q: Why do I need to grant storage permission?**
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
**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).
**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>
[![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)
## Disclaimer ## Disclaimer
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement. This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service. **SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
The application is purely a user interface that facilitates communication between your device and existing third-party services.
You are solely responsible for: You are solely responsible for:
1. Ensuring your use of this software complies with your local laws. 1. Ensuring your use of this software complies with your local laws.
@@ -73,3 +106,8 @@ You are solely responsible for:
3. Any legal consequences resulting from the misuse of this tool. 3. Any legal consequences resulting from the misuse of this tool.
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use. The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
> [!TIP]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
Binary file not shown.
Binary file not shown.
Binary file not shown.
+74 -2
View File
@@ -5,6 +5,7 @@
-keep class io.flutter.view.** { *; } -keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; } -keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; } -keep class io.flutter.plugins.** { *; }
-keep class io.flutter.embedding.** { *; }
# Ignore missing Play Core classes (not used, but referenced by Flutter) # Ignore missing Play Core classes (not used, but referenced by Flutter)
-dontwarn com.google.android.play.core.splitcompat.** -dontwarn com.google.android.play.core.splitcompat.**
@@ -14,13 +15,22 @@
# Ignore missing javax.xml.stream (not used on Android) # Ignore missing javax.xml.stream (not used on Android)
-dontwarn javax.xml.stream.** -dontwarn javax.xml.stream.**
# Go backend (gobackend.aar) # Go backend (gobackend.aar) - CRITICAL for release builds
-keep class gobackend.** { *; } -keep class gobackend.** { *; }
-keep class go.** { *; } -keep class go.** { *; }
-keep interface gobackend.** { *; }
-keepclassmembers class gobackend.** { *; }
# Go mobile binding internals
-keep class org.golang.** { *; }
-dontwarn org.golang.**
# FFmpeg Kit # FFmpeg Kit
-keep class com.arthenica.ffmpegkit.** { *; } -keep class com.arthenica.ffmpegkit.** { *; }
-keep class com.arthenica.smartexception.** { *; } -keep class com.arthenica.smartexception.** { *; }
# FFmpeg Kit (new fork package)
-keep class com.antonkarpenko.ffmpegkit.** { *; }
-keep class com.antonkarpenko.smartexception.** { *; }
# Apache Tika (if used by FFmpeg) # Apache Tika (if used by FFmpeg)
-dontwarn org.apache.tika.** -dontwarn org.apache.tika.**
@@ -30,15 +40,77 @@
native <methods>; native <methods>;
} }
# Kotlin coroutines # Kotlin coroutines - expanded rules
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** { -keepclassmembers class kotlinx.coroutines.** {
volatile <fields>; volatile <fields>;
} }
-keepclassmembernames class kotlinx.** {
volatile <fields>;
}
-dontwarn kotlinx.coroutines.**
# Kotlin serialization
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
-dontwarn kotlin.**
-keep class kotlin.** { *; }
-keep class kotlin.Metadata { *; }
# Keep MainActivity and related classes
-keep class com.zarz.spotiflac.** { *; }
# Prevent R8 from removing metadata # Prevent R8 from removing metadata
-keepattributes *Annotation* -keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
-keepattributes Signature -keepattributes Signature
-keepattributes Exceptions -keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes EnclosingMethod
# JSON parsing (used by Go backend responses)
-keep class org.json.** { *; }
# Shared Preferences
-keep class androidx.datastore.** { *; }
-dontwarn androidx.datastore.**
# Flutter Plugins - CRITICAL: Prevent R8 from removing plugin implementations
# Path Provider
-keep class io.flutter.plugins.pathprovider.** { *; }
-keep class dev.flutter.pigeon.** { *; }
# Local Notifications
-keep class com.dexterous.** { *; }
-keep class com.dexterous.flutterlocalnotifications.** { *; }
# Receive Sharing Intent
-keep class com.kasem.receive_sharing_intent.** { *; }
# Permission Handler
-keep class com.baseflow.permissionhandler.** { *; }
# File Picker
-keep class com.mr.flutter.plugin.filepicker.** { *; }
# URL Launcher
-keep class io.flutter.plugins.urllauncher.** { *; }
# Share Plus
-keep class dev.fluttercommunity.plus.share.** { *; }
# Device Info Plus
-keep class dev.fluttercommunity.plus.device_info.** { *; }
# Open File
-keep class com.crazecoder.openfile.** { *; }
# Sqflite
-keep class com.tekartik.sqflite.** { *; }
# Dynamic Color
-keep class io.material.** { *; }
# Keep all Flutter plugin registrants
-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; }
-keep class ** extends io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
@@ -1,23 +1,154 @@
package com.zarz.spotiflac package com.zarz.spotiflac
import android.content.Intent import android.content.Intent
import android.os.Build
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import gobackend.Gobackend import gobackend.Gobackend
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.Locale
class MainActivity: FlutterActivity() { class MainActivity: FlutterActivity() {
private val CHANNEL = "com.zarz.spotiflac/backend" private val CHANNEL = "com.zarz.spotiflac/backend"
private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
companion object {
// Minimum API level we consider "safe" for Impeller (Android 10+)
private const val SAFE_API_FOR_IMPELLER = 29
// Known problematic GPU patterns (lowercase)
private val PROBLEMATIC_GPU_PATTERNS = listOf(
"adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm
"adreno (tm) 4", // Adreno 400 series - some have issues
"mali-4", // Mali-400 series - old ARM GPUs
"mali-t6", // Mali-T600 series
"mali-t7", // Mali-T700 series (some)
"powervr sgx", // PowerVR SGX series - old Imagination GPUs
"powervr ge8320", // PowerVR GE8320 - known issues
"gc1000", // Vivante GC1000
"gc2000", // Vivante GC2000
)
// Known problematic chipsets/hardware (lowercase)
private val PROBLEMATIC_CHIPSETS = listOf(
"mt6762", // MediaTek Helio P22 with PowerVR GE8320
"mt6765", // MediaTek Helio P35 with PowerVR GE8320
"mt8768", // MediaTek tablet chip
"mp0873", // MediaTek variant
"msm8974", // Snapdragon 800/801 with Adreno 330
"msm8226", // Snapdragon 400 with Adreno 305
"msm8926", // Snapdragon 400 with Adreno 305
"apq8084", // Snapdragon 805 (some issues)
)
// Known problematic device models (lowercase)
private val PROBLEMATIC_MODELS = listOf(
"sm-t220", // Samsung Tab A7 Lite
"sm-t225", // Samsung Tab A7 Lite LTE
"hammerhead", // Nexus 5 (Adreno 330)
)
}
/**
* Override Flutter shell args to disable Impeller on problematic devices.
* This is called before the Flutter engine starts.
*/
override fun getFlutterShellArgs(): FlutterShellArgs {
val args = super.getFlutterShellArgs()
if (shouldDisableImpeller()) {
// Log for debugging
android.util.Log.i("SpotiFLAC", "Legacy/problematic GPU detected: Disabling Impeller for ${Build.MODEL}")
android.util.Log.i("SpotiFLAC", "Device: ${Build.MANUFACTURER} ${Build.MODEL}, SDK: ${Build.VERSION.SDK_INT}")
android.util.Log.i("SpotiFLAC", "Hardware: ${Build.HARDWARE}, Board: ${Build.BOARD}")
// Disable Impeller, forcing Skia renderer
args.add("--enable-impeller=false")
} else {
android.util.Log.i("SpotiFLAC", "Using Impeller renderer for ${Build.MODEL}")
}
return args
}
/**
* Check if device should use Skia instead of Impeller.
* Returns true for devices with old/problematic GPUs or old Android versions.
*/
private fun shouldDisableImpeller(): Boolean {
val hardware = Build.HARDWARE.lowercase(Locale.ROOT)
val board = Build.BOARD.lowercase(Locale.ROOT)
val model = Build.MODEL.lowercase(Locale.ROOT)
val device = Build.DEVICE.lowercase(Locale.ROOT)
// 1. Check for explicitly problematic device models
for (problematicModel in PROBLEMATIC_MODELS) {
if (model.contains(problematicModel) || device.contains(problematicModel)) {
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
return true
}
}
// 2. Check for problematic chipsets
for (chipset in PROBLEMATIC_CHIPSETS) {
if (hardware.contains(chipset) || board.contains(chipset)) {
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
return true
}
}
// 3. For Android < 10 (API 29), be more aggressive about disabling Impeller
if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) {
// For older Android, check GPU renderer if available
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
// Check for known problematic GPUs
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) {
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
return true
}
}
// For very old Android (< 8.0), always use Skia as Vulkan support is spotty
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
return true
}
}
// 4. For Android 10+, still check for known problematic GPUs
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) {
android.util.Log.i("SpotiFLAC", "Matched problematic GPU: $pattern")
return true
}
}
return false
}
/**
* Try to get GPU renderer string.
* Note: This may return empty on some devices before OpenGL context is created.
*/
private fun getGpuRenderer(): String {
return try {
// This might not work before GL context is created,
// but worth trying for additional detection
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
} catch (e: Exception) {
""
}
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
// Update the intent so receive_sharing_intent can access the new data // Update the intent so receive_sharing_intent can access the new data
@@ -117,6 +248,13 @@ class MainActivity: FlutterActivity() {
} }
result.success(null) result.success(null)
} }
"cancelDownload" -> {
val itemId = call.argument<String>("item_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.cancelDownload(itemId)
}
result.success(null)
}
"setDownloadDirectory" -> { "setDownloadDirectory" -> {
val path = call.argument<String>("path") ?: "" val path = call.argument<String>("path") ?: ""
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -132,6 +270,28 @@ class MainActivity: FlutterActivity() {
} }
result.success(response) 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" -> { "buildFilename" -> {
val template = call.argument<String>("template") ?: "" val template = call.argument<String>("template") ?: ""
val metadata = call.argument<String>("metadata") ?: "{}" val metadata = call.argument<String>("metadata") ?: "{}"
@@ -151,8 +311,9 @@ class MainActivity: FlutterActivity() {
val spotifyId = call.argument<String>("spotify_id") ?: "" val spotifyId = call.argument<String>("spotify_id") ?: ""
val trackName = call.argument<String>("track_name") ?: "" val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: "" val artistName = call.argument<String>("artist_name") ?: ""
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.fetchLyrics(spotifyId, trackName, artistName) Gobackend.fetchLyrics(spotifyId, trackName, artistName, durationMs)
} }
result.success(response) result.success(response)
} }
@@ -161,8 +322,9 @@ class MainActivity: FlutterActivity() {
val trackName = call.argument<String>("track_name") ?: "" val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: "" val artistName = call.argument<String>("artist_name") ?: ""
val filePath = call.argument<String>("file_path") ?: "" val filePath = call.argument<String>("file_path") ?: ""
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath) Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs)
} }
result.success(response) result.success(response)
} }
@@ -247,9 +409,10 @@ class MainActivity: FlutterActivity() {
"searchDeezerAll" -> { "searchDeezerAll" -> {
val query = call.argument<String>("query") ?: "" val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15 val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 3 val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong()) Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
} }
result.success(response) result.success(response)
} }
@@ -275,6 +438,13 @@ class MainActivity: FlutterActivity() {
} }
result.success(response) result.success(response)
} }
"getDeezerExtendedMetadata" -> {
val trackId = call.argument<String>("track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getDeezerExtendedMetadata(trackId)
}
result.success(response)
}
"convertSpotifyToDeezer" -> { "convertSpotifyToDeezer" -> {
val resourceType = call.argument<String>("resource_type") ?: "" val resourceType = call.argument<String>("resource_type") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: "" val spotifyId = call.argument<String>("spotify_id") ?: ""
@@ -290,6 +460,43 @@ class MainActivity: FlutterActivity() {
} }
result.success(response) 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 // Log methods
"getLogs" -> { "getLogs" -> {
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
@@ -429,6 +636,14 @@ class MainActivity: FlutterActivity() {
} }
result.success(null) 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" -> { "searchTracksWithExtensions" -> {
val query = call.argument<String>("query") ?: "" val query = call.argument<String>("query") ?: ""
val limit = call.argument<Int>("limit") ?: 20 val limit = call.argument<Int>("limit") ?: 20
@@ -444,6 +659,14 @@ class MainActivity: FlutterActivity() {
} }
result.success(response) 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" -> { "removeExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: "" val extensionId = call.argument<String>("extension_id") ?: ""
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -572,6 +795,30 @@ class MainActivity: FlutterActivity() {
} }
result.success(response) result.success(response)
} }
"getAlbumWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val albumId = call.argument<String>("album_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getAlbumWithExtensionJSON(extensionId, albumId)
}
result.success(response)
}
"getPlaylistWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val playlistId = call.argument<String>("playlist_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId)
}
result.success(response)
}
"getArtistWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val artistId = call.argument<String>("artist_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getArtistWithExtensionJSON(extensionId, artistId)
}
result.success(response)
}
// Extension Post-Processing API // Extension Post-Processing API
"runPostProcessing" -> { "runPostProcessing" -> {
val filePath = call.argument<String>("file_path") ?: "" val filePath = call.argument<String>("file_path") ?: ""
@@ -630,6 +877,21 @@ class MainActivity: FlutterActivity() {
} }
result.success(null) 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() else -> result.notImplemented()
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -637,37 +899,5 @@ class MainActivity: FlutterActivity() {
} }
} }
} }
// FFmpeg method channel
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FFMPEG_CHANNEL).setMethodCallHandler { call, result ->
scope.launch {
try {
when (call.method) {
"execute" -> {
val command = call.argument<String>("command") ?: ""
val session = withContext(Dispatchers.IO) {
FFmpegKit.execute(command)
}
val returnCode = session.returnCode
val output = session.output ?: ""
result.success(mapOf(
"success" to ReturnCode.isSuccess(returnCode),
"returnCode" to (returnCode?.value ?: -1),
"output" to output
))
}
"getVersion" -> {
val session = withContext(Dispatchers.IO) {
FFmpegKit.execute("-version")
}
result.success(session.output ?: "unknown")
}
else -> result.notImplemented()
}
} catch (e: Exception) {
result.error("FFMPEG_ERROR", e.message, null)
}
}
}
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 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});
}
+19
View File
@@ -0,0 +1,19 @@
files:
- source: /lib/l10n/arb/app_en.arb
translation: /lib/l10n/arb/app_%locale%.arb
languages_mapping:
locale:
# Short codes for single-variant languages
de: de
es: es
fr: fr
hi: hi
id: id
ja: ja
ko: ko
nl: nl
pt: pt
ru: ru
# Full codes for Chinese variants
zh-CN: zh_CN
zh-TW: zh_TW
+132 -360
View File
@@ -2,100 +2,40 @@ package gobackend
import ( import (
"bufio" "bufio"
"encoding/base64" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
type AmazonDownloader struct { type AmazonDownloader struct {
client *http.Client 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
} }
var ( var (
// Global Amazon downloader instance for connection reuse
globalAmazonDownloader *AmazonDownloader globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once amazonDownloaderOnce sync.Once
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
) )
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint // AfkarXYZResponse is the response from AfkarXYZ API
type DoubleDoubleSubmitResponse struct { type AfkarXYZResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
ID string `json:"id"` Data struct {
DirectLink string `json:"direct_link"`
FileName string `json:"file_name"`
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))
// Exact match
if normExpected == normFound {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Check first artist (before comma or feat)
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
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
}
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration
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 { func amazonIsASCIIString(s string) bool {
for _, r := range s { for _, r := range s {
if r > 127 { if r > 127 {
@@ -105,254 +45,79 @@ func amazonIsASCIIString(s string) bool {
return true return true
} }
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
func NewAmazonDownloader() *AmazonDownloader { func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() { amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{ globalAmazonDownloader = &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC client: NewHTTPClientWithTimeout(120 * time.Second),
regions: []string{"us", "eu"}, // Same regions as PC
apiCallResetTime: time.Now(),
} }
}) })
return globalAmazonDownloader return globalAmazonDownloader
} }
// waitForRateLimit implements rate limiting similar to PC version func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
// Max 9 requests per minute with 7 second delay between requests apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
func (a *AmazonDownloader) waitForRateLimit() {
amazonRateLimitMu.Lock()
defer amazonRateLimitMu.Unlock()
now := time.Now() GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
// Reset counter every minute req, err := http.NewRequest("GET", apiURL, nil)
if now.Sub(a.apiCallResetTime) >= time.Minute { if err != nil {
a.apiCallCount = 0 return "", "", fmt.Errorf("failed to create request: %w", err)
a.apiCallResetTime = now
} }
// If we've hit the limit (9 requests per minute), wait until next minute req.Header.Set("User-Agent", getRandomUserAgent())
if a.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(a.apiCallResetTime) resp, err := a.client.Do(req)
if waitTime > 0 { if err != nil {
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
time.Sleep(waitTime) }
a.apiCallCount = 0 defer resp.Body.Close()
a.apiCallResetTime = time.Now()
} if resp.StatusCode != 200 {
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
} }
// Add delay between requests (7 seconds like PC version) body, err := io.ReadAll(resp.Body)
if !a.lastAPICallTime.IsZero() { if err != nil {
timeSinceLastCall := now.Sub(a.lastAPICallTime) return "", "", fmt.Errorf("failed to read response: %w", err)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
} }
// Update tracking var apiResp AfkarXYZResponse
a.lastAPICallTime = time.Now() if err := json.Unmarshal(body, &apiResp); err != nil {
a.apiCallCount++ 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"
}
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)
// Build base URL for DoubleDouble service
// Decode base64 service URL (same as PC)
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!")
// Build download URL
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 { func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Initialize item progress (required for all downloads) ctx := context.Background()
if itemID != "" { if itemID != "" {
StartItemProgress(itemID) StartItemProgress(itemID)
defer CompleteItemProgress(itemID) defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
} }
req, err := http.NewRequest("GET", downloadURL, nil) if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
} }
@@ -361,6 +126,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
if err != nil { if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -370,7 +138,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
} }
expectedSize := resp.ContentLength expectedSize := resp.ContentLength
// Set total bytes if available
if expectedSize > 0 && itemID != "" { if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize) SetItemBytesTotal(itemID, expectedSize)
} }
@@ -380,26 +147,24 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
return err return err
} }
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024) bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64 var written int64
if itemID != "" { if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID) pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body) written, err = io.Copy(pw, resp.Body)
} else { } else {
// Fallback: direct copy without progress tracking
written, err = io.Copy(bufWriter, resp.Body) written, err = io.Copy(bufWriter, resp.Body)
} }
// Flush buffer before checking for errors
flushErr := bufWriter.Flush() flushErr := bufWriter.Flush()
closeErr := out.Close() closeErr := out.Close()
// Check for any errors
if err != nil { if err != nil {
os.Remove(outputPath) os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err) return fmt.Errorf("download interrupted: %w", err)
} }
if flushErr != nil { if flushErr != nil {
@@ -411,13 +176,12 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
return fmt.Errorf("failed to close file: %w", closeErr) return fmt.Errorf("failed to close file: %w", closeErr)
} }
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize { if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath) os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
} }
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024)) GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
return nil return nil
} }
@@ -435,29 +199,21 @@ type AmazonDownloadResult struct {
ISRC string ISRC string
} }
// downloadFromAmazon downloads a track using the request parameters
// Uses DoubleDouble service (same as PC version)
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader() downloader := NewAmazonDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
} }
// Get Amazon URL from SongLink
songlink := NewSongLinkClient() songlink := NewSongLinkClient()
var availability *TrackAvailability var availability *TrackAvailability
var err error var err error
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx") if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
if strings.HasPrefix(req.SpotifyID, "deezer:") {
// Extract Deezer ID and use Deezer-based lookup
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID) GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID) availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" { } else if req.SpotifyID != "" {
// Use Spotify ID
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
} else { } else {
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup") return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
@@ -471,30 +227,21 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)") return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
} }
// Create output directory if needed
if req.OutputDir != "." { if req.OutputDir != "." {
if err := os.MkdirAll(req.OutputDir, 0755); err != nil { if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err) return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
} }
} }
// Download using DoubleDouble service (same as PC) // Download using AfkarXYZ API
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir) downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL)
if err != nil { if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
} }
// Verify artist matches GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
}
// Log match found filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
// Build filename using Spotify metadata (more accurate)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName, "title": req.TrackName,
"artist": req.ArtistName, "artist": req.ArtistName,
"album": req.AlbumName, "album": req.AlbumName,
@@ -505,7 +252,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
filename = sanitizeFilename(filename) + ".flac" filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename) outputPath := filepath.Join(req.OutputDir, filename)
// Check if file already exists
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
} }
@@ -522,37 +268,35 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
req.TrackName, req.TrackName,
req.ArtistName, req.ArtistName,
req.EmbedLyrics, req.EmbedLyrics,
int64(req.DurationMS),
) )
}() }()
// Download audio file with item ID for progress tracking // Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return AmazonDownloadResult{}, ErrDownloadCancelled
}
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err) return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
} }
// Wait for parallel operations to complete // Wait for parallel operations to complete
<-parallelDone <-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" { if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0) SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID) SetItemFinalizing(req.ItemID)
} }
// Log track info from DoubleDouble (for debugging)
if trackName != "" && artistName != "" {
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
}
// Read existing metadata from downloaded file BEFORE embedding
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
existingMeta, metaErr := ReadMetadata(outputPath) existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber actualDiscNum := req.DiscNumber
actualDate := req.ReleaseDate
actualAlbum := req.AlbumName
actualTitle := req.TrackName
actualArtist := req.ArtistName
if metaErr == nil && existingMeta != nil { if metaErr == nil && existingMeta != nil {
// Use file metadata if it has valid track/disc numbers and request doesn't have them
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) { if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber actualTrackNum = existingMeta.TrackNumber
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber) GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
@@ -561,49 +305,80 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
actualDiscNum = existingMeta.DiscNumber actualDiscNum = existingMeta.DiscNumber
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber) GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
} }
if existingMeta.Date != "" && req.ReleaseDate == "" {
actualDate = existingMeta.Date
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
}
if existingMeta.Album != "" && req.AlbumName == "" {
actualAlbum = existingMeta.Album
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
}
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
} }
// Embed metadata using Spotify data (more accurate than DoubleDouble)
// But preserve track/disc numbers from file if they were better
metadata := Metadata{ metadata := Metadata{
Title: req.TrackName, Title: actualTitle,
Artist: req.ArtistName, Artist: actualArtist,
Album: req.AlbumName, Album: actualAlbum,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate, Date: actualDate,
TrackNumber: actualTrackNum, TrackNumber: actualTrackNum,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNum, DiscNumber: actualDiscNum,
ISRC: req.ISRC, ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
} }
// Use cover data from parallel fetch
var coverData []byte var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil { if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
coverData = parallelResult.CoverData coverData = parallelResult.CoverData
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} else {
existingCover, coverErr := ExtractCoverArt(outputPath)
if coverErr == nil && len(existingCover) > 0 {
coverData = existingCover
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
} else {
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
}
} }
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err) GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
} }
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) lyricsMode := req.LyricsMode
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { if lyricsMode == "" {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) lyricsMode = "embed"
} else { }
fmt.Println("[Amazon] Lyrics embedded successfully")
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 { } 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")
// Read actual quality from the downloaded FLAC file
// Amazon API doesn't provide quality info, but we can read it from the file itself
quality, err := GetAudioQuality(outputPath) quality, err := GetAudioQuality(outputPath)
if err != nil { if err != nil {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err) GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
@@ -611,8 +386,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} }
// Read metadata from file AFTER embedding to get accurate values
// This ensures we return what's actually in the file
finalMeta, metaReadErr := ReadMetadata(outputPath) finalMeta, metaReadErr := ReadMetadata(outputPath)
if metaReadErr == nil && finalMeta != nil { if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n", GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
@@ -620,7 +393,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
actualTrackNum = finalMeta.TrackNumber actualTrackNum = finalMeta.TrackNumber
actualDiscNum = finalMeta.DiscNumber actualDiscNum = finalMeta.DiscNumber
if finalMeta.Date != "" { if finalMeta.Date != "" {
// Use date from file if available
req.ReleaseDate = finalMeta.Date req.ReleaseDate = finalMeta.Date
} }
} }
+78
View File
@@ -0,0 +1,78 @@
package gobackend
import (
"context"
"errors"
"sync"
)
// ErrDownloadCancelled is returned when a download is cancelled by the user.
var ErrDownloadCancelled = errors.New("download cancelled")
type cancelEntry struct {
cancel context.CancelFunc
canceled bool
}
var (
cancelMu sync.Mutex
cancelMap = make(map[string]*cancelEntry)
)
func initDownloadCancel(itemID string) context.Context {
if itemID == "" {
return context.Background()
}
cancelMu.Lock()
defer cancelMu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{
cancel: cancel,
canceled: false,
}
return ctx
}
func cancelDownload(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
if ok {
entry.canceled = true
if entry.cancel != nil {
entry.cancel()
}
} else {
cancelMap[itemID] = &cancelEntry{canceled: true}
}
cancelMu.Unlock()
RemoveItemProgress(itemID)
}
func isDownloadCancelled(itemID string) bool {
if itemID == "" {
return false
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
canceled := ok && entry.canceled
cancelMu.Unlock()
return canceled
}
func clearDownloadCancel(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
delete(cancelMap, itemID)
cancelMu.Unlock()
}
+30 -25
View File
@@ -4,18 +4,19 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"regexp"
"strings" "strings"
) )
// Spotify image size codes (same as PC version)
const ( const (
spotifySize300 = "ab67616d00001e02" // 300x300 (small) spotifySize300 = "ab67616d00001e02"
spotifySize640 = "ab67616d0000b273" // 640x640 (medium) spotifySize640 = "ab67616d0000b273"
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000) spotifySizeMax = "ab67616d000082c1"
) )
// convertSmallToMedium upgrades 300x300 cover URL to 640x640 // Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
// Same logic as PC version for consistency var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
func convertSmallToMedium(imageURL string) string { func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) { if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1) return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
@@ -23,8 +24,6 @@ func convertSmallToMedium(imageURL string) string {
return imageURL 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) { func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
if coverURL == "" { if coverURL == "" {
return nil, fmt.Errorf("no cover URL provided") return nil, fmt.Errorf("no cover URL provided")
@@ -32,20 +31,19 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
GoLog("[Cover] Original URL: %s", coverURL) GoLog("[Cover] Original URL: %s", coverURL)
// First upgrade small (300) to medium (640) - always do this
downloadURL := convertSmallToMedium(coverURL) downloadURL := convertSmallToMedium(coverURL)
if downloadURL != coverURL { if downloadURL != coverURL {
GoLog("[Cover] Upgraded 300x300 → 640x640") GoLog("[Cover] Upgraded 300x300 → 640x640")
} }
// Then upgrade to max quality if requested
if maxQuality { if maxQuality {
maxURL := upgradeToMaxQuality(downloadURL) maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL { if maxURL != downloadURL {
downloadURL = maxURL downloadURL = maxURL
GoLog("[Cover] Upgraded to max resolution (~2000x2000)") // Log already printed by upgradeToMaxQuality for Deezer
} else { if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Max resolution not available, using 640x640") GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
} }
} }
@@ -53,7 +51,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
client := NewHTTPClientWithTimeout(DefaultTimeout) client := NewHTTPClientWithTimeout(DefaultTimeout)
// Create request with User-Agent (required by Spotify CDN)
req, err := http.NewRequest("GET", downloadURL, nil) req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
@@ -74,8 +71,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return nil, fmt.Errorf("failed to read cover data: %w", err) return nil, fmt.Errorf("failed to read cover data: %w", err)
} }
// Calculate approximate resolution from file size
// JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB
sizeKB := len(data) / 1024 sizeKB := len(data) / 1024
var resolution string var resolution string
if sizeKB > 200 { if sizeKB > 200 {
@@ -90,23 +85,33 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return data, nil return data, nil
} }
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
// Same logic as PC version - directly replaces 640x640 size code with max resolution
// No HEAD verification needed - Spotify CDN always serves max resolution if available
func upgradeToMaxQuality(coverURL string) string { func upgradeToMaxQuality(coverURL string) string {
// Spotify image URLs can be upgraded by changing the size parameter // Spotify CDN upgrade
// Format: https://i.scdn.co/image/ab67616d0000b273...
// ab67616d0000b273 = 640x640
// ab67616d000082c1 = Max resolution (~2000x2000)
if strings.Contains(coverURL, spotifySize640) { if strings.Contains(coverURL, spotifySize640) {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1) return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
} }
// Deezer CDN upgrade
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL)
}
return coverURL return coverURL
} }
// GetCoverFromSpotify gets cover URL from Spotify metadata func upgradeDeezerCover(coverURL string) string {
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return coverURL
}
// Replace any size pattern with 1800x1800
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
if upgraded != coverURL {
GoLog("[Cover] Deezer: upgraded to 1800x1800")
}
return upgraded
}
func GetCoverFromSpotify(imageURL string, maxQuality bool) string { func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" { if imageURL == "" {
return "" return ""
+430 -126
View File
@@ -22,27 +22,23 @@ const (
deezerCacheTTL = 10 * time.Minute deezerCacheTTL = 10 * time.Minute
// Parallel ISRC fetching settings deezerMaxParallelISRC = 10
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
) )
// DeezerClient handles Deezer API interactions (no auth required)
type DeezerClient struct { type DeezerClient struct {
httpClient *http.Client httpClient *http.Client
searchCache map[string]*cacheEntry searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry artistCache map[string]*cacheEntry
isrcCache map[string]string // trackID -> ISRC cache isrcCache map[string]string
cacheMu sync.RWMutex cacheMu sync.RWMutex
} }
// Singleton instance
var ( var (
deezerClient *DeezerClient deezerClient *DeezerClient
deezerClientOnce sync.Once deezerClientOnce sync.Once
) )
// GetDeezerClient returns singleton Deezer client
func GetDeezerClient() *DeezerClient { func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() { deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{ deezerClient = &DeezerClient{
@@ -56,16 +52,15 @@ func GetDeezerClient() *DeezerClient {
return deezerClient return deezerClient
} }
// Deezer API response types
type deezerTrack struct { type deezerTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Duration int `json:"duration"` // in seconds Duration int `json:"duration"`
TrackPosition int `json:"track_position"` TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"` DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Link string `json:"link"` Link string `json:"link"`
ReleaseDate string `json:"release_date"` // Sometimes at track level ReleaseDate string `json:"release_date"`
Artist deezerArtist `json:"artist"` Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"` Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"` Contributors []deezerArtist `json:"contributors"`
@@ -88,12 +83,10 @@ type deezerAlbumSimple struct {
CoverMedium string `json:"cover_medium"` CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"` CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` // Sometimes at album level ReleaseDate string `json:"release_date"`
RecordType string `json:"record_type"`
} }
// ... (skip other structs as they are fine/unchanged) ...
// ... (in convertTrack) ...
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
artistName := track.Artist.Name artistName := track.Artist.Name
if len(track.Contributors) > 0 { if len(track.Contributors) > 0 {
@@ -115,7 +108,6 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
albumImage = track.Album.Cover albumImage = track.Album.Cover
} }
// Try to find release date
releaseDate := track.ReleaseDate releaseDate := track.ReleaseDate
if releaseDate == "" { if releaseDate == "" {
releaseDate = track.Album.ReleaseDate releaseDate = track.Album.ReleaseDate
@@ -129,7 +121,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
AlbumArtist: track.Artist.Name, AlbumArtist: track.Artist.Name,
DurationMS: track.Duration * 1000, DurationMS: track.Duration * 1000,
Images: albumImage, Images: albumImage,
ReleaseDate: releaseDate, // Added this ReleaseDate: releaseDate,
TrackNumber: track.TrackPosition, TrackNumber: track.TrackPosition,
DiscNumber: track.DiskNumber, DiscNumber: track.DiskNumber,
ExternalURL: track.Link, ExternalURL: track.Link,
@@ -137,16 +129,25 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
} }
} }
type deezerGenre struct {
ID int `json:"id"`
Name string `json:"name"`
}
type deezerAlbumFull struct { type deezerAlbumFull struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Cover string `json:"cover"` Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"` CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"` CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"` NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"` // album, single, ep, compile RecordType string `json:"record_type"`
Label string `json:"label"`
Genres struct {
Data []deezerGenre `json:"data"`
} `json:"genres"`
Artist deezerArtist `json:"artist"` Artist deezerArtist `json:"artist"`
Contributors []deezerArtist `json:"contributors"` Contributors []deezerArtist `json:"contributors"`
Tracks struct { Tracks struct {
@@ -181,12 +182,38 @@ type deezerPlaylistFull struct {
} `json:"tracks"` } `json:"tracks"`
} }
// SearchAll searches for tracks and artists on Deezer func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
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)
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit) albumLimit := 5
playlistLimit := 5
if filter != "" {
switch filter {
case "track":
trackLimit = 50
artistLimit = 0
albumLimit = 0
playlistLimit = 0
case "artist":
trackLimit = 0
artistLimit = 20
albumLimit = 0
playlistLimit = 0
case "album":
trackLimit = 0
artistLimit = 0
albumLimit = 20
playlistLimit = 0
case "playlist":
trackLimit = 0
artistLimit = 0
albumLimit = 0
playlistLimit = 20
}
}
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d:%d:%d:%s", query, trackLimit, artistLimit, albumLimit, playlistLimit, filter)
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
@@ -197,73 +224,190 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
result := &SearchAllResult{ result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0), Tracks: make([]TrackMetadata, 0, trackLimit),
Artists: make([]SearchArtistResult, 0), Artists: make([]SearchArtistResult, 0, artistLimit),
Albums: make([]SearchAlbumResult, 0, albumLimit),
Playlists: make([]SearchPlaylistResult, 0, playlistLimit),
} }
// Search tracks - NO ISRC fetch for performance if trackLimit > 0 {
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit) trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL) GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
var trackResp struct { var trackResp struct {
Data []deezerTrack `json:"data"` Data []deezerTrack `json:"data"`
Error *struct { Error *struct {
Type string `json:"type"` Type string `json:"type"`
Message string `json:"message"` Message string `json:"message"`
Code int `json:"code"` Code int `json:"code"`
} `json:"error"` } `json:"error"`
} }
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil { if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
GoLog("[Deezer] Track search failed: %v\n", err) GoLog("[Deezer] Track search failed: %v\n", err)
return nil, fmt.Errorf("deezer track search failed: %w", err) return nil, fmt.Errorf("deezer track search failed: %w", err)
} }
if trackResp.Error != nil { if trackResp.Error != nil {
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message) GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code) return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
} }
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data)) GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
for _, track := range trackResp.Data { for _, track := range trackResp.Data {
// Convert directly without fetching ISRC - much faster result.Tracks = append(result.Tracks, c.convertTrack(track))
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)
var artistResp struct {
Data []deezerArtist `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
if artistResp.Error != nil {
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
} else {
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
for _, artist := range artistResp.Data {
result.Artists = append(result.Artists, SearchArtistResult{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: c.getBestArtistImage(artist),
Followers: artist.NbFan,
Popularity: 0,
})
}
} }
} else {
GoLog("[Deezer] Artist search failed: %v\n", err)
} }
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists)) if artistLimit > 0 {
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
var artistResp struct {
Data []deezerArtist `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
if artistResp.Error != nil {
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
} else {
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
for _, artist := range artistResp.Data {
result.Artists = append(result.Artists, SearchArtistResult{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: c.getBestArtistImage(artist),
Followers: artist.NbFan,
Popularity: 0,
})
}
}
} else {
GoLog("[Deezer] Artist search failed: %v\n", err)
}
}
if albumLimit > 0 {
albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit)
GoLog("[Deezer] Fetching albums from: %s\n", albumURL)
var albumResp struct {
Data []struct {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
NbTracks int `json:"nb_tracks"`
ReleaseDate string `json:"release_date"`
RecordType string `json:"record_type"`
Artist deezerArtist `json:"artist"`
} `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, albumURL, &albumResp); err == nil {
if albumResp.Error != nil {
GoLog("[Deezer] Album API error: type=%s, code=%d, message=%s\n", albumResp.Error.Type, albumResp.Error.Code, albumResp.Error.Message)
} else {
GoLog("[Deezer] Got %d albums from API\n", len(albumResp.Data))
for _, album := range albumResp.Data {
coverURL := album.CoverXL
if coverURL == "" {
coverURL = album.CoverBig
}
if coverURL == "" {
coverURL = album.CoverMedium
}
if coverURL == "" {
coverURL = album.Cover
}
albumType := album.RecordType
if albumType == "compile" {
albumType = "compilation"
}
result.Albums = append(result.Albums, SearchAlbumResult{
ID: fmt.Sprintf("deezer:%d", album.ID),
Name: album.Title,
Artists: album.Artist.Name,
Images: coverURL,
ReleaseDate: album.ReleaseDate,
TotalTracks: album.NbTracks,
AlbumType: albumType,
})
}
}
} else {
GoLog("[Deezer] Album search failed: %v\n", err)
}
}
if playlistLimit > 0 {
playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit)
GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL)
var playlistResp struct {
Data []struct {
ID int64 `json:"id"`
Title string `json:"title"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbTracks int `json:"nb_tracks"`
User struct {
Name string `json:"name"`
} `json:"user"`
} `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
if err := c.getJSON(ctx, playlistURL, &playlistResp); err == nil {
if playlistResp.Error != nil {
GoLog("[Deezer] Playlist API error: type=%s, code=%d, message=%s\n", playlistResp.Error.Type, playlistResp.Error.Code, playlistResp.Error.Message)
} else {
GoLog("[Deezer] Got %d playlists from API\n", len(playlistResp.Data))
for _, playlist := range playlistResp.Data {
pictureURL := playlist.PictureXL
if pictureURL == "" {
pictureURL = playlist.PictureBig
}
if pictureURL == "" {
pictureURL = playlist.PictureMedium
}
if pictureURL == "" {
pictureURL = playlist.Picture
}
result.Playlists = append(result.Playlists, SearchPlaylistResult{
ID: fmt.Sprintf("deezer:%d", playlist.ID),
Name: playlist.Title,
Owner: playlist.User.Name,
Images: pictureURL,
TotalTracks: playlist.NbTracks,
})
}
}
} else {
GoLog("[Deezer] Playlist search failed: %v\n", err)
}
}
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
// Cache result
c.cacheMu.Lock() c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{ c.searchCache[cacheKey] = &cacheEntry{
data: result, data: result,
@@ -274,7 +418,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
return result, nil return result, nil
} }
// GetTrack fetches a single track by Deezer ID
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) { func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID) trackURL := fmt.Sprintf(deezerTrackURL, trackID)
@@ -288,8 +431,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
}, nil }, 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) { func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() { if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
@@ -315,28 +456,75 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
artistName = strings.Join(names, ", ") artistName = strings.Join(names, ", ")
} }
var genres []string
for _, g := range album.Genres.Data {
if g.Name != "" {
genres = append(genres, g.Name)
}
}
genreStr := strings.Join(genres, ", ")
info := AlbumInfoMetadata{ info := AlbumInfoMetadata{
TotalTracks: album.NbTracks, TotalTracks: album.NbTracks,
Name: album.Title, Name: album.Title,
ReleaseDate: album.ReleaseDate, ReleaseDate: album.ReleaseDate,
Artists: artistName, Artists: artistName,
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
Images: albumImage, Images: albumImage,
Genre: genreStr,
Label: album.Label,
} }
// Fetch ISRCs in parallel allTracks := album.Tracks.Data
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data)) if album.NbTracks > len(allTracks) {
// Normalize record_type (Deezer uses "compile" instead of "compilation") GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerAlbumURL, albumID), len(allTracks))
for len(allTracks) < album.NbTracks {
var tracksResp struct {
Data []deezerTrack `json:"data"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
GoLog("[Deezer] Warning: failed to fetch album tracks page: %v", err)
break
}
if len(tracksResp.Data) == 0 {
break
}
allTracks = append(allTracks, tracksResp.Data...)
if tracksResp.Next == "" {
break
}
tracksURL = tracksResp.Next
}
GoLog("[Deezer] Fetched total %d tracks for album", len(allTracks))
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
albumType := album.RecordType albumType := album.RecordType
if albumType == "compile" { if albumType == "compile" {
albumType = "compilation" albumType = "compilation"
} }
for _, track := range album.Tracks.Data { for i, track := range allTracks {
trackIDStr := fmt.Sprintf("%d", track.ID) trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr] isrc := isrcMap[trackIDStr]
trackNum := track.TrackPosition
if trackNum == 0 {
trackNum = i + 1
}
tracks = append(tracks, AlbumTrackMetadata{ tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID), SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name, Artists: track.Artist.Name,
@@ -346,7 +534,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
DurationMS: track.Duration * 1000, DurationMS: track.Duration * 1000,
Images: albumImage, Images: albumImage,
ReleaseDate: album.ReleaseDate, ReleaseDate: album.ReleaseDate,
TrackNumber: track.TrackPosition, TrackNumber: trackNum,
TotalTracks: album.NbTracks, TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber, DiscNumber: track.DiskNumber,
ExternalURL: track.Link, ExternalURL: track.Link,
@@ -371,7 +559,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
return result, nil return result, nil
} }
// GetArtist fetches artist with albums
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) { func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() { if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
@@ -380,7 +567,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Fetch artist info
artistURL := fmt.Sprintf(deezerArtistURL, artistID) artistURL := fmt.Sprintf(deezerArtistURL, artistID)
var artist deezerArtistFull var artist deezerArtistFull
if err := c.getJSON(ctx, artistURL, &artist); err != nil { if err := c.getJSON(ctx, artistURL, &artist); err != nil {
@@ -395,7 +581,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
Popularity: 0, Popularity: 0,
} }
// Fetch artist albums
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID)) albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
var albumsResp struct { var albumsResp struct {
Data []struct { Data []struct {
@@ -407,7 +592,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
CoverMedium string `json:"cover_medium"` CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"` CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
RecordType string `json:"record_type"` // album, single, ep, compile RecordType string `json:"record_type"`
} `json:"data"` } `json:"data"`
} }
@@ -457,8 +642,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
return result, nil 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) { func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID) playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
@@ -481,11 +664,43 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
info.Owner.Name = playlist.Title info.Owner.Name = playlist.Title
info.Owner.Images = playlistImage info.Owner.Images = playlistImage
// Fetch ISRCs in parallel allTracks := playlist.Tracks.Data
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data)) if playlist.NbTracks > len(allTracks) {
for _, track := range playlist.Tracks.Data { GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerPlaylistURL, playlistID), len(allTracks))
for len(allTracks) < playlist.NbTracks {
var tracksResp struct {
Data []deezerTrack `json:"data"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
GoLog("[Deezer] Warning: failed to fetch playlist tracks page: %v", err)
break
}
if len(tracksResp.Data) == 0 {
break
}
allTracks = append(allTracks, tracksResp.Data...)
if tracksResp.Next == "" {
break
}
tracksURL = tracksResp.Next
}
GoLog("[Deezer] Fetched total %d tracks for playlist", len(allTracks))
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
for _, track := range allTracks {
albumImage := track.Album.CoverXL albumImage := track.Album.CoverXL
if albumImage == "" { if albumImage == "" {
albumImage = track.Album.CoverBig albumImage = track.Album.CoverBig
@@ -520,15 +735,11 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
}, nil }, nil
} }
// SearchByISRC searches for a track by ISRC using direct endpoint
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) { 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) directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
var track deezerTrack var track deezerTrack
if err := c.getJSON(ctx, directURL, &track); err != nil { 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) searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
var resp struct { var resp struct {
Data []deezerTrack `json:"data"` Data []deezerTrack `json:"data"`
@@ -543,7 +754,6 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
return &result, nil return &result, nil
} }
// Check if we got a valid response (ID > 0)
if track.ID == 0 { if track.ID == 0 {
return nil, fmt.Errorf("no track found for ISRC: %s", isrc) return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
} }
@@ -561,16 +771,25 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
return &track, nil return &track, nil
} }
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string { func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
result := make(map[string]string) result := make(map[string]string, len(tracks))
var resultMu sync.Mutex var resultMu sync.Mutex
// First, check cache for existing ISRCs
var tracksToFetch []deezerTrack var tracksToFetch []deezerTrack
var directISRCs map[string]string
c.cacheMu.RLock() c.cacheMu.RLock()
for _, track := range tracks { for _, track := range tracks {
trackIDStr := fmt.Sprintf("%d", track.ID) 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 { if isrc, ok := c.isrcCache[trackIDStr]; ok {
result[trackIDStr] = isrc result[trackIDStr] = isrc
} else { } else {
@@ -578,12 +797,18 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
} }
} }
c.cacheMu.RUnlock() 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 { if len(tracksToFetch) == 0 {
return result return result
} }
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, deezerMaxParallelISRC) sem := make(chan struct{}, deezerMaxParallelISRC)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -592,7 +817,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
go func(t deezerTrack) { go func(t deezerTrack) {
defer wg.Done() defer wg.Done()
// Acquire semaphore
select { select {
case sem <- struct{}{}: case sem <- struct{}{}:
defer func() { <-sem }() defer func() { <-sem }()
@@ -606,7 +830,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return return
} }
// Store in result and cache
resultMu.Lock() resultMu.Lock()
result[trackIDStr] = fullTrack.ISRC result[trackIDStr] = fullTrack.ISRC
resultMu.Unlock() resultMu.Unlock()
@@ -621,10 +844,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return result 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) { func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
// Check cache first
c.cacheMu.RLock() c.cacheMu.RLock()
if isrc, ok := c.isrcCache[trackID]; ok { if isrc, ok := c.isrcCache[trackID]; ok {
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
@@ -632,13 +852,11 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Fetch from API
fullTrack, err := c.fetchFullTrack(ctx, trackID) fullTrack, err := c.fetchFullTrack(ctx, trackID)
if err != nil { if err != nil {
return "", err return "", err
} }
// Cache the result
c.cacheMu.Lock() c.cacheMu.Lock()
c.isrcCache[trackID] = fullTrack.ISRC c.isrcCache[trackID] = fullTrack.ISRC
c.cacheMu.Unlock() c.cacheMu.Unlock()
@@ -685,6 +903,94 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
return album.Cover return album.Cover
} }
type AlbumExtendedMetadata struct {
Genre string
Label string
}
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
}
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
}
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)
}
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
if isrc == "" {
return nil, fmt.Errorf("empty ISRC")
}
track, err := c.SearchByISRC(ctx, isrc)
if err != nil {
return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
}
deezerID := strings.TrimPrefix(track.SpotifyID, "deezer:")
if deezerID == "" {
return nil, fmt.Errorf("track found but no Deezer ID")
}
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
}
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error { func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
@@ -711,7 +1017,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
return json.Unmarshal(body, dst) return json.Unmarshal(body, dst)
} }
// parseDeezerURL is internal function, returns type and ID
func parseDeezerURL(input string) (string, string, error) { func parseDeezerURL(input string) (string, string, error) {
trimmed := strings.TrimSpace(input) trimmed := strings.TrimSpace(input)
if trimmed == "" { if trimmed == "" {
@@ -729,7 +1034,6 @@ func parseDeezerURL(input string) (string, string, error) {
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
// Skip language prefix if present (e.g., /en/, /fr/)
if len(parts) > 0 && len(parts[0]) == 2 { if len(parts) > 0 && len(parts[0]) == 2 {
parts = parts[1:] parts = parts[1:]
} }
+43 -37
View File
@@ -10,7 +10,6 @@ import (
"time" "time"
) )
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
type ISRCIndex struct { type ISRCIndex struct {
index map[string]string // ISRC (uppercase) -> file path index map[string]string // ISRC (uppercase) -> file path
outputDir string outputDir string
@@ -18,30 +17,42 @@ type ISRCIndex struct {
mu sync.RWMutex mu sync.RWMutex
} }
// Global ISRC index cache (per output directory)
var ( var (
isrcIndexCache = make(map[string]*ISRCIndex) isrcIndexCache = make(map[string]*ISRCIndex)
isrcIndexCacheMu sync.RWMutex isrcIndexCacheMu sync.RWMutex
isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
isrcIndexTTL = 5 * time.Minute
) )
// GetISRCIndex returns or builds an ISRC index for the given directory
func GetISRCIndex(outputDir string) *ISRCIndex { func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first
isrcIndexCacheMu.RLock() isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir] idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock() isrcIndexCacheMu.RUnlock()
// Return cached index if still valid
if exists && time.Since(idx.buildTime) < isrcIndexTTL { if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx return idx
} }
// Build new index // Slow path: need to build index
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
mu := buildLock.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
// Double-check cache after acquiring lock (another goroutine may have built it)
isrcIndexCacheMu.RLock()
idx, exists = isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
return buildISRCIndex(outputDir) return buildISRCIndex(outputDir)
} }
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
// Same implementation as PC version for consistency
func buildISRCIndex(outputDir string) *ISRCIndex { func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{ idx := &ISRCIndex{
index: make(map[string]string), index: make(map[string]string),
@@ -56,7 +67,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
startTime := time.Now() startTime := time.Now()
fileCount := 0 fileCount := 0
// Walk directory - only check .flac files
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error { filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() { if err != nil || info.IsDir() {
return nil return nil
@@ -67,22 +77,19 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return nil return nil
} }
// Read ISRC from file
metadata, err := ReadMetadata(path) metadata, err := ReadMetadata(path)
if err != nil || metadata.ISRC == "" { if err != nil || metadata.ISRC == "" {
return nil return nil
} }
// Store in index (uppercase for case-insensitive matching)
idx.index[strings.ToUpper(metadata.ISRC)] = path idx.index[strings.ToUpper(metadata.ISRC)] = path
fileCount++ fileCount++
return nil return nil
}) })
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n", fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond)) outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
// Cache the index
isrcIndexCacheMu.Lock() isrcIndexCacheMu.Lock()
isrcIndexCache[outputDir] = idx isrcIndexCache[outputDir] = idx
isrcIndexCacheMu.Unlock() isrcIndexCacheMu.Unlock()
@@ -90,7 +97,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return idx return idx
} }
// lookup checks if an ISRC exists in the index (internal, returns bool)
func (idx *ISRCIndex) lookup(isrc string) (string, bool) { func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
if isrc == "" { if isrc == "" {
return "", false return "", false
@@ -103,14 +109,22 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
return path, exists return path, exists
} }
// Lookup checks if an ISRC exists in the index (gomobile compatible) func (idx *ISRCIndex) remove(isrc string) {
// Returns filepath if found, empty string if not found if isrc == "" {
return
}
idx.mu.Lock()
defer idx.mu.Unlock()
delete(idx.index, strings.ToUpper(isrc))
}
func (idx *ISRCIndex) Lookup(isrc string) (string, error) { func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
path, _ := idx.lookup(isrc) path, _ := idx.lookup(isrc)
return path, nil return path, nil
} }
// Add adds a new ISRC to the index (call after successful download)
func (idx *ISRCIndex) Add(isrc, filePath string) { func (idx *ISRCIndex) Add(isrc, filePath string) {
if isrc == "" || filePath == "" { if isrc == "" || filePath == "" {
return return
@@ -122,33 +136,37 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
idx.index[strings.ToUpper(isrc)] = filePath idx.index[strings.ToUpper(isrc)] = filePath
} }
// InvalidateCache clears the ISRC index cache for a directory
func InvalidateISRCCache(outputDir string) { func InvalidateISRCCache(outputDir string) {
isrcIndexCacheMu.Lock() isrcIndexCacheMu.Lock()
delete(isrcIndexCache, outputDir) delete(isrcIndexCache, outputDir)
isrcIndexCacheMu.Unlock() isrcIndexCacheMu.Unlock()
} }
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
// Uses ISRC index for fast lookup
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) { func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
if isrc == "" || outputDir == "" { if isrc == "" || outputDir == "" {
return "", false return "", false
} }
// Use index for fast lookup
idx := GetISRCIndex(outputDir) idx := GetISRCIndex(outputDir)
return idx.lookup(isrc) filePath, exists := idx.lookup(isrc)
if !exists {
return "", false
}
if !CheckFileExists(filePath) {
// Stale index entry; remove it and return not found.
idx.remove(isrc)
return "", false
}
return filePath, true
} }
// 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) { func CheckISRCExists(outputDir, isrc string) (string, error) {
filepath, _ := checkISRCExistsInternal(outputDir, isrc) filepath, _ := checkISRCExistsInternal(outputDir, isrc)
return filepath, nil return filepath, nil
} }
// CheckFileExists checks if a file with the given name exists
func CheckFileExists(filePath string) bool { func CheckFileExists(filePath string) bool {
info, err := os.Stat(filePath) info, err := os.Stat(filePath)
if err != nil { if err != nil {
@@ -157,7 +175,6 @@ func CheckFileExists(filePath string) bool {
return !info.IsDir() && info.Size() > 0 return !info.IsDir() && info.Size() > 0
} }
// FileExistenceResult represents the result of checking if a file exists
type FileExistenceResult struct { type FileExistenceResult struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Exists bool `json:"exists"` Exists bool `json:"exists"`
@@ -166,11 +183,7 @@ type FileExistenceResult struct {
ArtistName string `json:"artist_name,omitempty"` 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) { func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
// Parse input JSON
var tracks []struct { var tracks []struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
@@ -182,10 +195,8 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
results := make([]FileExistenceResult, len(tracks)) results := make([]FileExistenceResult, len(tracks))
// Build ISRC index from output directory (scan once)
isrcIdx := GetISRCIndex(outputDir) isrcIdx := GetISRCIndex(outputDir)
// Check each track against the index (parallel)
var wg sync.WaitGroup var wg sync.WaitGroup
for i, track := range tracks { for i, track := range tracks {
wg.Add(1) wg.Add(1)
@@ -216,7 +227,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
wg.Wait() wg.Wait()
// Return results as JSON
resultJSON, err := json.Marshal(results) resultJSON, err := json.Marshal(results)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to marshal results: %w", err) return "", fmt.Errorf("failed to marshal results: %w", err)
@@ -225,8 +235,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
return string(resultJSON), nil return string(resultJSON), nil
} }
// PreBuildISRCIndex pre-builds the ISRC index for a directory
// Call this when app starts or when entering album/playlist screen
func PreBuildISRCIndex(outputDir string) error { func PreBuildISRCIndex(outputDir string) error {
if outputDir == "" { if outputDir == "" {
return fmt.Errorf("output directory is required") return fmt.Errorf("output directory is required")
@@ -236,8 +244,6 @@ func PreBuildISRCIndex(outputDir string) error {
return nil return nil
} }
// AddToISRCIndex adds a new file to the ISRC index after successful download
// This avoids rebuilding the entire index
func AddToISRCIndex(outputDir, isrc, filePath string) { func AddToISRCIndex(outputDir, isrc, filePath string) {
if outputDir == "" || isrc == "" || filePath == "" { if outputDir == "" || isrc == "" || filePath == "" {
return return
+448 -216
View File
File diff suppressed because it is too large Load Diff
+84 -112
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension management functionality
package gobackend package gobackend
import ( import (
@@ -15,14 +14,10 @@ import (
"github.com/dop251/goja" "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 { func compareVersions(v1, v2 string) int {
// Parse version parts
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".") parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".") parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
// Pad shorter version with zeros
maxLen := len(parts1) maxLen := len(parts1)
if len(parts2) > maxLen { if len(parts2) > maxLen {
maxLen = len(parts2) maxLen = len(parts2)
@@ -48,19 +43,18 @@ func compareVersions(v1, v2 string) int {
return 0 return 0
} }
// LoadedExtension represents an extension that has been loaded into memory
type LoadedExtension struct { type LoadedExtension struct {
ID string `json:"id"` ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"` Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"` // Goja VM instance (not serialized) VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"` // Extension's data directory DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"` // Where extension files are extracted SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"` // Full path to icon file (if exists) IconPath string `json:"icon_path"`
} }
// ExtensionManager manages all loaded extensions
type ExtensionManager struct { type ExtensionManager struct {
mu sync.RWMutex mu sync.RWMutex
extensions map[string]*LoadedExtension extensions map[string]*LoadedExtension
@@ -68,13 +62,11 @@ type ExtensionManager struct {
dataDir string // Base directory for extension data dataDir string // Base directory for extension data
} }
// Global extension manager instance
var ( var (
globalExtManager *ExtensionManager globalExtManager *ExtensionManager
globalExtManagerOnce sync.Once globalExtManagerOnce sync.Once
) )
// GetExtensionManager returns the global extension manager instance
func GetExtensionManager() *ExtensionManager { func GetExtensionManager() *ExtensionManager {
globalExtManagerOnce.Do(func() { globalExtManagerOnce.Do(func() {
globalExtManager = &ExtensionManager{ globalExtManager = &ExtensionManager{
@@ -84,7 +76,6 @@ func GetExtensionManager() *ExtensionManager {
return globalExtManager return globalExtManager
} }
// SetDirectories sets the extensions and data directories
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error { func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -92,7 +83,6 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.extensionsDir = extensionsDir m.extensionsDir = extensionsDir
m.dataDir = dataDir m.dataDir = dataDir
// Create directories if they don't exist
if err := os.MkdirAll(extensionsDir, 0755); err != nil { if err := os.MkdirAll(extensionsDir, 0755); err != nil {
return fmt.Errorf("failed to create extensions directory: %w", err) return fmt.Errorf("failed to create extensions directory: %w", err)
} }
@@ -103,9 +93,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
return nil return nil
} }
// LoadExtensionFromFile loads an extension from a .spotiflac-ext file
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) { func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
} }
@@ -117,7 +105,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
} }
defer zipReader.Close() defer zipReader.Close()
// Find and read manifest.json
var manifestData []byte var manifestData []byte
var hasIndexJS bool var hasIndexJS bool
for _, file := range zipReader.File { for _, file := range zipReader.File {
@@ -146,13 +133,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return nil, fmt.Errorf("Invalid extension package: index.js not found") return nil, fmt.Errorf("Invalid extension package: index.js not found")
} }
// Parse and validate manifest
manifest, err := ParseManifest(manifestData) manifest, err := ParseManifest(manifestData)
if err != nil { if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err) return nil, fmt.Errorf("Invalid extension manifest: %w", err)
} }
// Check if extension already loaded - if so, try upgrade (check without holding lock for long)
m.mu.RLock() m.mu.RLock()
existing, exists := m.extensions[manifest.Name] existing, exists := m.extensions[manifest.Name]
var existingVersion string var existingVersion string
@@ -164,7 +149,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
m.mu.RUnlock() m.mu.RUnlock()
if exists { if exists {
// Check if this is an upgrade
versionCompare := compareVersions(manifest.Version, existingVersion) versionCompare := compareVersions(manifest.Version, existingVersion)
if versionCompare > 0 { if versionCompare > 0 {
// This is an upgrade - call UpgradeExtension // This is an upgrade - call UpgradeExtension
@@ -176,29 +160,23 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
} }
} }
// Now acquire write lock for the rest of the operation
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
// Double-check extension wasn't added while we were waiting for lock
if _, exists := m.extensions[manifest.Name]; exists { if _, exists := m.extensions[manifest.Name]; exists {
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName) return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
} }
// Create extension directory
extDir := filepath.Join(m.extensionsDir, manifest.Name) extDir := filepath.Join(m.extensionsDir, manifest.Name)
if err := os.MkdirAll(extDir, 0755); err != nil { if err := os.MkdirAll(extDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension directory: %w", err) return nil, fmt.Errorf("failed to create extension directory: %w", err)
} }
// Extract all files (preserving directory structure)
for _, file := range zipReader.File { for _, file := range zipReader.File {
if file.FileInfo().IsDir() { if file.FileInfo().IsDir() {
continue continue
} }
// Preserve relative path within the zip (support subdirectories)
// Clean the path to prevent path traversal attacks
relPath := filepath.Clean(file.Name) relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name) GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
@@ -206,19 +184,16 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
} }
destPath := filepath.Join(extDir, relPath) destPath := filepath.Join(extDir, relPath)
// Create parent directories if needed
destDir := filepath.Dir(destPath) destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil { if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err) return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
} }
// Create destination file
destFile, err := os.Create(destPath) destFile, err := os.Create(destPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err) return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
} }
// Copy content
srcFile, err := file.Open() srcFile, err := file.Open()
if err != nil { if err != nil {
destFile.Close() destFile.Close()
@@ -233,13 +208,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
} }
} }
// Create data directory for extension
extDataDir := filepath.Join(m.dataDir, manifest.Name) extDataDir := filepath.Join(m.dataDir, manifest.Name)
if err := os.MkdirAll(extDataDir, 0755); err != nil { if err := os.MkdirAll(extDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension data directory: %w", err) return nil, fmt.Errorf("failed to create extension data directory: %w", err)
} }
// Create loaded extension
ext := &LoadedExtension{ ext := &LoadedExtension{
ID: manifest.Name, ID: manifest.Name,
Manifest: manifest, Manifest: manifest,
@@ -261,25 +234,20 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return ext, nil return ext, nil
} }
// initializeVM creates and initializes the Goja VM for an extension
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
// Create new Goja runtime
vm := goja.New() vm := goja.New()
ext.VM = vm ext.VM = vm
// Read index.js
indexPath := filepath.Join(ext.SourceDir, "index.js") indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath) jsCode, err := os.ReadFile(indexPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to read index.js: %w", err) return fmt.Errorf("failed to read index.js: %w", err)
} }
// Create extension runtime and register sandboxed APIs
runtime := NewExtensionRuntime(ext) runtime := NewExtensionRuntime(ext)
runtime.RegisterAPIs(vm) runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm) runtime.RegisterGoBackendAPIs(vm)
// Set up console.log for debugging
console := vm.NewObject() console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value { console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]interface{}, len(call.Arguments)) args := make([]interface{}, len(call.Arguments))
@@ -291,12 +259,10 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
}) })
vm.Set("console", console) vm.Set("console", console)
// Set up registerExtension function
var registeredExtension goja.Value var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value { vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 { if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0] registeredExtension = call.Arguments[0]
// Also set it as global 'extension' variable for later access
vm.Set("extension", call.Arguments[0]) vm.Set("extension", call.Arguments[0])
} }
return goja.Undefined() return goja.Undefined()
@@ -316,7 +282,6 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
return nil return nil
} }
// UnloadExtension unloads an extension by ID
func (m *ExtensionManager) UnloadExtension(extensionID string) error { func (m *ExtensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -344,8 +309,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return nil 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) { func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -357,7 +320,6 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
return ext, nil return ext, nil
} }
// GetAllExtensions returns all loaded extensions
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension { func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -369,7 +331,6 @@ func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
return result return result
} }
// SetExtensionEnabled enables or disables an extension
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error { func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -391,7 +352,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return nil return nil
} }
// LoadExtensionsFromDirectory scans a directory and loads all valid extensions
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) { func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
var loaded []string var loaded []string
var errors []error var errors []error
@@ -406,7 +366,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() { if entry.IsDir() {
// Check if it's an extracted extension directory
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json") manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
if _, err := os.Stat(manifestPath); err == nil { if _, err := os.Stat(manifestPath); err == nil {
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name())) ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
@@ -418,7 +377,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
} }
} }
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") { } else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
// Load from package file
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name())) ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
if err != nil { if err != nil {
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err) GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
@@ -432,12 +390,10 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
return loaded, errors return loaded, errors
} }
// loadExtensionFromDirectory loads an extension from an already extracted directory
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) { func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
// Read manifest
manifestPath := filepath.Join(dirPath, "manifest.json") manifestPath := filepath.Join(dirPath, "manifest.json")
manifestData, err := os.ReadFile(manifestPath) manifestData, err := os.ReadFile(manifestPath)
if err != nil { if err != nil {
@@ -450,25 +406,21 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return nil, fmt.Errorf("Invalid extension manifest: %w", err) return nil, fmt.Errorf("Invalid extension manifest: %w", err)
} }
// Check if index.js exists
indexPath := filepath.Join(dirPath, "index.js") indexPath := filepath.Join(dirPath, "index.js")
if _, err := os.Stat(indexPath); os.IsNotExist(err) { if _, err := os.Stat(indexPath); os.IsNotExist(err) {
return nil, fmt.Errorf("Extension is missing index.js file") return nil, fmt.Errorf("Extension is missing index.js file")
} }
// Check if extension already loaded - skip silently (for directory loading on startup)
if existing, exists := m.extensions[manifest.Name]; exists { if existing, exists := m.extensions[manifest.Name]; exists {
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName) GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
return existing, nil return existing, nil
} }
// Create data directory for extension
extDataDir := filepath.Join(m.dataDir, manifest.Name) extDataDir := filepath.Join(m.dataDir, manifest.Name)
if err := os.MkdirAll(extDataDir, 0755); err != nil { if err := os.MkdirAll(extDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension data directory: %w", err) return nil, fmt.Errorf("failed to create extension data directory: %w", err)
} }
// Create loaded extension
ext := &LoadedExtension{ ext := &LoadedExtension{
ID: manifest.Name, ID: manifest.Name,
Manifest: manifest, Manifest: manifest,
@@ -499,7 +451,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return ext, nil return ext, nil
} }
// RemoveExtension completely removes an extension (unload + delete files)
func (m *ExtensionManager) RemoveExtension(extensionID string) error { func (m *ExtensionManager) RemoveExtension(extensionID string) error {
ext, err := m.GetExtension(extensionID) ext, err := m.GetExtension(extensionID)
if err != nil { if err != nil {
@@ -526,7 +477,6 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
return nil return nil
} }
// UpgradeExtension upgrades an existing extension from a new package file
// Only allows upgrades (new version > current version), not downgrades // Only allows upgrades (new version > current version), not downgrades
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) { func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
// Validate file extension // Validate file extension
@@ -541,7 +491,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
} }
defer zipReader.Close() defer zipReader.Close()
// Find and read manifest.json
var manifestData []byte var manifestData []byte
var hasIndexJS bool var hasIndexJS bool
for _, file := range zipReader.File { for _, file := range zipReader.File {
@@ -570,13 +519,11 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return nil, fmt.Errorf("Invalid extension package: index.js not found") return nil, fmt.Errorf("Invalid extension package: index.js not found")
} }
// Parse and validate manifest
newManifest, err := ParseManifest(manifestData) newManifest, err := ParseManifest(manifestData)
if err != nil { if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err) return nil, fmt.Errorf("Invalid extension manifest: %w", err)
} }
// Check if extension exists
m.mu.RLock() m.mu.RLock()
existing, exists := m.extensions[newManifest.Name] existing, exists := m.extensions[newManifest.Name]
m.mu.RUnlock() m.mu.RUnlock()
@@ -612,19 +559,15 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
} }
} }
// Recreate extension directory
if err := os.MkdirAll(extDir, 0755); err != nil { if err := os.MkdirAll(extDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension directory: %w", err) return nil, fmt.Errorf("failed to create extension directory: %w", err)
} }
// Extract all files from new package (preserving directory structure)
for _, file := range zipReader.File { for _, file := range zipReader.File {
if file.FileInfo().IsDir() { if file.FileInfo().IsDir() {
continue continue
} }
// Preserve relative path within the zip (support subdirectories)
// Clean the path to prevent path traversal attacks
relPath := filepath.Clean(file.Name) relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name) GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
@@ -632,19 +575,16 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
} }
destPath := filepath.Join(extDir, relPath) destPath := filepath.Join(extDir, relPath)
// Create parent directories if needed
destDir := filepath.Dir(destPath) destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil { if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err) return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
} }
// Create destination file
destFile, err := os.Create(destPath) destFile, err := os.Create(destPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err) return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
} }
// Copy content
srcFile, err := file.Open() srcFile, err := file.Open()
if err != nil { if err != nil {
destFile.Close() destFile.Close()
@@ -659,7 +599,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
} }
} }
// Create new loaded extension (reusing data directory, preserving enabled state)
ext := &LoadedExtension{ ext := &LoadedExtension{
ID: newManifest.Name, ID: newManifest.Name,
Manifest: newManifest, Manifest: newManifest,
@@ -684,7 +623,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return ext, nil return ext, nil
} }
// ExtensionUpgradeInfo holds information about extension upgrade check
type ExtensionUpgradeInfo struct { type ExtensionUpgradeInfo struct {
ExtensionID string `json:"extension_id"` ExtensionID string `json:"extension_id"`
CurrentVersion string `json:"current_version"` CurrentVersion string `json:"current_version"`
@@ -693,8 +631,6 @@ type ExtensionUpgradeInfo struct {
IsInstalled bool `json:"is_installed"` IsInstalled bool `json:"is_installed"`
} }
// checkExtensionUpgradeInternal checks if a package file is an upgrade for an existing extension
// Internal function that returns struct
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) { func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
// Validate file extension // Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
@@ -708,7 +644,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
} }
defer zipReader.Close() defer zipReader.Close()
// Find and read manifest.json
var manifestData []byte var manifestData []byte
for _, file := range zipReader.File { for _, file := range zipReader.File {
name := filepath.Base(file.Name) name := filepath.Base(file.Name)
@@ -730,13 +665,11 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
return nil, fmt.Errorf("manifest.json not found") return nil, fmt.Errorf("manifest.json not found")
} }
// Parse manifest
newManifest, err := ParseManifest(manifestData) newManifest, err := ParseManifest(manifestData)
if err != nil { if err != nil {
return nil, fmt.Errorf("Invalid manifest: %w", err) return nil, fmt.Errorf("Invalid manifest: %w", err)
} }
// Check if extension exists
m.mu.RLock() m.mu.RLock()
existing, exists := m.extensions[newManifest.Name] existing, exists := m.extensions[newManifest.Name]
m.mu.RUnlock() m.mu.RUnlock()
@@ -752,7 +685,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
info.CurrentVersion = "" info.CurrentVersion = ""
info.CanUpgrade = false info.CanUpgrade = false
} else { } else {
// Compare versions
info.CurrentVersion = existing.Manifest.Version info.CurrentVersion = existing.Manifest.Version
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0 info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
} }
@@ -760,7 +692,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
return info, nil return info, nil
} }
// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) { func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
info, err := m.checkExtensionUpgradeInternal(filePath) info, err := m.checkExtensionUpgradeInternal(filePath)
if err != nil { if err != nil {
@@ -775,37 +706,36 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetInstalledExtensionsJSON returns all extensions as JSON for Flutter
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions() extensions := m.GetAllExtensions()
type ExtensionInfo struct { type ExtensionInfo struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"` Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"` IconPath string `json:"icon_path,omitempty"`
Types []ExtensionType `json:"types"` Types []ExtensionType `json:"types"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error_message,omitempty"` Error string `json:"error_message,omitempty"`
Settings []ExtensionSetting `json:"settings,omitempty"` Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"quality_options,omitempty"` QualityOptions []QualityOption `json:"quality_options,omitempty"`
Permissions []string `json:"permissions"` Permissions []string `json:"permissions"`
HasMetadataProvider bool `json:"has_metadata_provider"` HasMetadataProvider bool `json:"has_metadata_provider"`
HasDownloadProvider bool `json:"has_download_provider"` HasDownloadProvider bool `json:"has_download_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"` SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"` TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"` PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
} }
infos := make([]ExtensionInfo, len(extensions)) infos := make([]ExtensionInfo, len(extensions))
for i, ext := range extensions { for i, ext := range extensions {
// Build permissions list
permissions := []string{} permissions := []string{}
for _, domain := range ext.Manifest.Permissions.Network { for _, domain := range ext.Manifest.Permissions.Network {
permissions = append(permissions, "network:"+domain) permissions = append(permissions, "network:"+domain)
@@ -822,7 +752,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
status = "disabled" status = "disabled"
} }
// Check for icon file
iconPath := "" iconPath := ""
if ext.Manifest.Icon != "" && ext.SourceDir != "" { if ext.Manifest.Icon != "" && ext.SourceDir != "" {
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon) possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
@@ -830,7 +759,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
iconPath = possibleIcon iconPath = possibleIcon
} }
} }
// Fallback: check for icon.png if not specified in manifest
if iconPath == "" && ext.SourceDir != "" { if iconPath == "" && ext.SourceDir != "" {
possibleIcon := filepath.Join(ext.SourceDir, "icon.png") possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
if _, err := os.Stat(possibleIcon); err == nil { if _, err := os.Stat(possibleIcon); err == nil {
@@ -860,6 +788,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
SearchBehavior: ext.Manifest.SearchBehavior, SearchBehavior: ext.Manifest.SearchBehavior,
TrackMatching: ext.Manifest.TrackMatching, TrackMatching: ext.Manifest.TrackMatching,
PostProcessing: ext.Manifest.PostProcessing, PostProcessing: ext.Manifest.PostProcessing,
Capabilities: ext.Manifest.Capabilities,
} }
} }
@@ -871,9 +800,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== Extension Lifecycle ====================
// InitializeExtension calls the extension's initialize method with settings
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error { func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -887,13 +813,11 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return fmt.Errorf("Extension failed to load. Please reinstall the extension") return fmt.Errorf("Extension failed to load. Please reinstall the extension")
} }
// Convert settings to JSON for passing to JS
settingsJSON, err := json.Marshal(settings) settingsJSON, err := json.Marshal(settings)
if err != nil { if err != nil {
return fmt.Errorf("Failed to save settings") return fmt.Errorf("Failed to save settings")
} }
// Call initialize function
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
var settings = %s; var settings = %s;
@@ -917,7 +841,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return err return err
} }
// Check result
if result != nil && !goja.IsUndefined(result) { if result != nil && !goja.IsUndefined(result) {
exported := result.Export() exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok { if resultMap, ok := exported.(map[string]interface{}); ok {
@@ -938,7 +861,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return nil return nil
} }
// CleanupExtension calls the extension's cleanup method
func (m *ExtensionManager) CleanupExtension(extensionID string) error { func (m *ExtensionManager) CleanupExtension(extensionID string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -949,10 +871,9 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
} }
if ext.VM == nil { if ext.VM == nil {
return nil // No VM, nothing to cleanup return nil
} }
// Call cleanup function
script := ` script := `
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
@@ -973,7 +894,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
return err return err
} }
// Check result
if result != nil && !goja.IsUndefined(result) { if result != nil && !goja.IsUndefined(result) {
exported := result.Export() exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok { if resultMap, ok := exported.(map[string]interface{}); ok {
@@ -992,7 +912,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
return nil return nil
} }
// UnloadAllExtensions unloads all extensions gracefully
func (m *ExtensionManager) UnloadAllExtensions() { func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Lock() m.mu.Lock()
extensionIDs := make([]string, 0, len(m.extensions)) extensionIDs := make([]string, 0, len(m.extensions))
@@ -1002,11 +921,64 @@ func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Unlock() m.mu.Unlock()
for _, id := range extensionIDs { for _, id := range extensionIDs {
// Call cleanup first
m.CleanupExtension(id) m.CleanupExtension(id)
// Then unload
m.UnloadExtension(id) m.UnloadExtension(id)
} }
GoLog("[Extension] All extensions unloaded\n") GoLog("[Extension] All extensions unloaded\n")
} }
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
}
+63 -89
View File
@@ -7,7 +7,6 @@ import (
"strings" "strings"
) )
// ExtensionType represents the type of extension
type ExtensionType string type ExtensionType string
const ( const (
@@ -15,7 +14,6 @@ const (
ExtensionTypeDownloadProvider ExtensionType = "download_provider" ExtensionTypeDownloadProvider ExtensionType = "download_provider"
) )
// SettingType represents the type of a setting field
type SettingType string type SettingType string
const ( const (
@@ -23,16 +21,15 @@ const (
SettingTypeNumber SettingType = "number" SettingTypeNumber SettingType = "number"
SettingTypeBool SettingType = "boolean" SettingTypeBool SettingType = "boolean"
SettingTypeSelect SettingType = "select" SettingTypeSelect SettingType = "select"
SettingTypeButton SettingType = "button" // Action button that calls a JS function
) )
// ExtensionPermissions defines what resources an extension can access
type ExtensionPermissions struct { type ExtensionPermissions struct {
Network []string `json:"network"` // List of allowed domains Network []string `json:"network"`
Storage bool `json:"storage"` // Whether extension can use storage API Storage bool `json:"storage"`
File bool `json:"file"` // Whether extension can use file API File bool `json:"file"`
} }
// ExtensionSetting defines a configurable setting for an extension
type ExtensionSetting struct { type ExtensionSetting struct {
Key string `json:"key"` Key string `json:"key"`
Type SettingType `json:"type"` Type SettingType `json:"type"`
@@ -41,18 +38,17 @@ type ExtensionSetting struct {
Required bool `json:"required,omitempty"` Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"` Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"` Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type Options []string `json:"options,omitempty"`
Action string `json:"action,omitempty"`
} }
// QualityOption represents a quality option for download providers
type QualityOption struct { type QualityOption struct {
ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128") ID string `json:"id"`
Label string `json:"label"` // Display name (e.g., "MP3 320kbps") Label string `json:"label"`
Description string `json:"description"` // Optional description (e.g., "Best quality MP3") Description string `json:"description"`
Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings Settings []QualitySpecificSetting `json:"settings,omitempty"`
} }
// QualitySpecificSetting represents a setting that's specific to a quality option
type QualitySpecificSetting struct { type QualitySpecificSetting struct {
Key string `json:"key"` Key string `json:"key"`
Type SettingType `json:"type"` Type SettingType `json:"type"`
@@ -61,71 +57,72 @@ type QualitySpecificSetting struct {
Required bool `json:"required,omitempty"` Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"` Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"` Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type Options []string `json:"options,omitempty"`
}
type SearchFilter struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Icon string `json:"icon,omitempty"`
} }
// SearchBehaviorConfig defines custom search behavior for an extension
type SearchBehaviorConfig struct { type SearchBehaviorConfig struct {
Enabled bool `json:"enabled"` // Whether extension provides custom search Enabled bool `json:"enabled"`
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box Placeholder string `json:"placeholder,omitempty"`
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab Primary bool `json:"primary,omitempty"`
Icon string `json:"icon,omitempty"` // Icon for search tab Icon string `json:"icon,omitempty"`
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3) ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
Filters []SearchFilter `json:"filters,omitempty"`
} }
// URLHandlerConfig defines custom URL handling for an extension
type URLHandlerConfig struct { type URLHandlerConfig struct {
Enabled bool `json:"enabled"` // Whether extension handles URLs Enabled bool `json:"enabled"`
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com") Patterns []string `json:"patterns,omitempty"`
} }
// TrackMatchingConfig defines custom track matching behavior
type TrackMatchingConfig struct { type TrackMatchingConfig struct {
CustomMatching bool `json:"customMatching"` // Whether extension handles matching CustomMatching bool `json:"customMatching"`
Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom" Strategy string `json:"strategy,omitempty"`
DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching DurationTolerance int `json:"durationTolerance,omitempty"`
} }
// PostProcessingHook defines a post-processing hook
type PostProcessingHook struct { type PostProcessingHook struct {
ID string `json:"id"` // Unique identifier ID string `json:"id"`
Name string `json:"name"` // Display name Name string `json:"name"`
Description string `json:"description,omitempty"` // Description Description string `json:"description,omitempty"`
DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default DefaultEnabled bool `json:"defaultEnabled,omitempty"`
SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"]) SupportedFormats []string `json:"supportedFormats,omitempty"`
} }
// PostProcessingConfig defines post-processing capabilities
type PostProcessingConfig struct { type PostProcessingConfig struct {
Enabled bool `json:"enabled"` // Whether extension provides post-processing Enabled bool `json:"enabled"`
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks Hooks []PostProcessingHook `json:"hooks,omitempty"`
} }
// ExtensionManifest represents the manifest.json of an extension
type ExtensionManifest struct { type ExtensionManifest struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"` Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png") Icon string `json:"icon,omitempty"`
Types []ExtensionType `json:"type"` Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"` Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"` Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"` MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon) SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
} }
// ManifestValidationError represents a validation error in the manifest
type ManifestValidationError struct { type ManifestValidationError struct {
Field string Field string
Message string Message string
@@ -135,7 +132,6 @@ func (e *ManifestValidationError) Error() string {
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message) return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
} }
// ParseManifest parses and validates a manifest from JSON bytes
func ParseManifest(data []byte) (*ExtensionManifest, error) { func ParseManifest(data []byte) (*ExtensionManifest, error) {
var manifest ExtensionManifest var manifest ExtensionManifest
if err := json.Unmarshal(data, &manifest); err != nil { if err := json.Unmarshal(data, &manifest); err != nil {
@@ -149,9 +145,7 @@ func ParseManifest(data []byte) (*ExtensionManifest, error) {
return &manifest, nil return &manifest, nil
} }
// Validate checks if the manifest has all required fields and valid values
func (m *ExtensionManifest) Validate() error { func (m *ExtensionManifest) Validate() error {
// Check required fields
if strings.TrimSpace(m.Name) == "" { if strings.TrimSpace(m.Name) == "" {
return &ManifestValidationError{Field: "name", Message: "name is required"} return &ManifestValidationError{Field: "name", Message: "name is required"}
} }
@@ -172,7 +166,6 @@ func (m *ExtensionManifest) Validate() error {
return &ManifestValidationError{Field: "type", Message: "at least one type is required"} return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
} }
// Validate extension types
for _, t := range m.Types { for _, t := range m.Types {
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider { if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
return &ManifestValidationError{ return &ManifestValidationError{
@@ -198,20 +191,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 // Select type requires options
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 { if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
return &ManifestValidationError{ return &ManifestValidationError{
@@ -219,12 +198,18 @@ func (m *ExtensionManifest) Validate() error {
Message: "select type requires options", 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 return nil
} }
// HasType checks if the extension has a specific type
func (m *ExtensionManifest) HasType(t ExtensionType) bool { func (m *ExtensionManifest) HasType(t ExtensionType) bool {
for _, et := range m.Types { for _, et := range m.Types {
if et == t { if et == t {
@@ -234,17 +219,14 @@ func (m *ExtensionManifest) HasType(t ExtensionType) bool {
return false return false
} }
// IsMetadataProvider returns true if extension provides metadata
func (m *ExtensionManifest) IsMetadataProvider() bool { func (m *ExtensionManifest) IsMetadataProvider() bool {
return m.HasType(ExtensionTypeMetadataProvider) return m.HasType(ExtensionTypeMetadataProvider)
} }
// IsDownloadProvider returns true if extension provides downloads
func (m *ExtensionManifest) IsDownloadProvider() bool { func (m *ExtensionManifest) IsDownloadProvider() bool {
return m.HasType(ExtensionTypeDownloadProvider) return m.HasType(ExtensionTypeDownloadProvider)
} }
// IsDomainAllowed checks if a domain is in the allowed network permissions
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool { func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain)) domain = strings.ToLower(strings.TrimSpace(domain))
for _, allowed := range m.Permissions.Network { for _, allowed := range m.Permissions.Network {
@@ -263,33 +245,27 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
return false return false
} }
// HasCustomSearch returns true if extension provides custom search
func (m *ExtensionManifest) HasCustomSearch() bool { func (m *ExtensionManifest) HasCustomSearch() bool {
return m.SearchBehavior != nil && m.SearchBehavior.Enabled return m.SearchBehavior != nil && m.SearchBehavior.Enabled
} }
// HasCustomMatching returns true if extension provides custom track matching
func (m *ExtensionManifest) HasCustomMatching() bool { func (m *ExtensionManifest) HasCustomMatching() bool {
return m.TrackMatching != nil && m.TrackMatching.CustomMatching return m.TrackMatching != nil && m.TrackMatching.CustomMatching
} }
// HasPostProcessing returns true if extension provides post-processing
func (m *ExtensionManifest) HasPostProcessing() bool { func (m *ExtensionManifest) HasPostProcessing() bool {
return m.PostProcessing != nil && m.PostProcessing.Enabled return m.PostProcessing != nil && m.PostProcessing.Enabled
} }
// HasURLHandler returns true if extension handles custom URLs
func (m *ExtensionManifest) HasURLHandler() bool { func (m *ExtensionManifest) HasURLHandler() bool {
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0 return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
} }
// MatchesURL checks if a URL matches any of the extension's URL patterns
func (m *ExtensionManifest) MatchesURL(urlStr string) bool { func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
if !m.HasURLHandler() { if !m.HasURLHandler() {
return false return false
} }
// Parse URL to get host
urlStr = strings.ToLower(strings.TrimSpace(urlStr)) urlStr = strings.ToLower(strings.TrimSpace(urlStr))
for _, pattern := range m.URLHandler.Patterns { for _, pattern := range m.URLHandler.Patterns {
pattern = strings.ToLower(strings.TrimSpace(pattern)) pattern = strings.ToLower(strings.TrimSpace(pattern))
@@ -301,7 +277,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
return false return false
} }
// GetPostProcessingHooks returns all post-processing hooks
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook { func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
if m.PostProcessing == nil { if m.PostProcessing == nil {
return nil return nil
@@ -309,7 +284,6 @@ func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
return m.PostProcessing.Hooks return m.PostProcessing.Hooks
} }
// ToJSON serializes the manifest to JSON
func (m *ExtensionManifest) ToJSON() ([]byte, error) { func (m *ExtensionManifest) ToJSON() ([]byte, error) {
return json.Marshal(m) return json.Marshal(m)
} }
+219 -18
View File
@@ -2,7 +2,9 @@
package gobackend package gobackend
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -29,6 +31,18 @@ type ExtTrackMetadata struct {
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
ProviderID string `json:"provider_id"` ProviderID string `json:"provider_id"`
ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
// Enrichment fields from Odesli/song.link
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
SpotifyID string `json:"spotify_id,omitempty"`
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
// Extended metadata from enrichment (can come from Deezer, Spotify, etc.)
Label string `json:"label,omitempty"` // Record label
Copyright string `json:"copyright,omitempty"` // Copyright information
Genre string `json:"genre,omitempty"` // Music genre(s)
} }
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields // ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
@@ -44,6 +58,7 @@ type ExtAlbumMetadata struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Artists string `json:"artists"` Artists string `json:"artists"`
ArtistID string `json:"artist_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
ReleaseDate string `json:"release_date,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
TotalTracks int `json:"total_tracks"` TotalTracks int `json:"total_tracks"`
@@ -54,11 +69,14 @@ type ExtAlbumMetadata struct {
// ExtArtistMetadata represents artist metadata from an extension // ExtArtistMetadata represents artist metadata from an extension
type ExtArtistMetadata struct { type ExtArtistMetadata struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
ImageURL string `json:"image_url,omitempty"` ImageURL string `json:"image_url,omitempty"`
Albums []ExtAlbumMetadata `json:"albums,omitempty"` HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background
ProviderID string `json:"provider_id"` Listeners int `json:"listeners,omitempty"` // Monthly listeners
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` // Popular tracks
ProviderID string `json:"provider_id"`
} }
// ExtSearchResult represents search results from an extension // ExtSearchResult represents search results from an extension
@@ -132,6 +150,10 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) 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 // Call extension's searchTracks function
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
@@ -177,7 +199,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
} }
} }
// Set provider ID on all tracks
for i := range searchResult.Tracks { for i := range searchResult.Tracks {
searchResult.Tracks[i].ProviderID = p.extension.ID searchResult.Tracks[i].ProviderID = p.extension.ID
} }
@@ -195,6 +216,10 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) 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(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') { if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') {
@@ -241,6 +266,10 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) 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(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') { if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
@@ -290,6 +319,10 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) 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(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') { if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') {
@@ -338,6 +371,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return track, nil // Extension disabled, return as-is 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 // Convert track to JSON for passing to JS
trackJSON, err := json.Marshal(track) trackJSON, err := json.Marshal(track)
if err != nil { if err != nil {
@@ -404,6 +441,10 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) 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(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') { if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
@@ -449,6 +490,10 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) 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(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') { if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') {
@@ -497,6 +542,10 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) 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 // Set up progress callback in VM
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 { if len(call.Arguments) > 0 {
@@ -683,7 +732,7 @@ func GetMetadataProviderPriority() []string {
// isBuiltInProvider checks if a provider ID is a built-in provider // isBuiltInProvider checks if a provider ID is a built-in provider
func isBuiltInProvider(providerID string) bool { func isBuiltInProvider(providerID string) bool {
switch providerID { switch providerID {
case "tidal", "qobuz", "amazon": case "tidal", "qobuz", "amazon", "deezer":
return true return true
default: default:
return false return false
@@ -699,6 +748,21 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
priority := GetProviderPriority() priority := GetProviderPriority()
extManager := GetExtensionManager() extManager := GetExtensionManager()
// If req.Service is a built-in provider, prioritize it first
// This handles user's explicit selection from the service picker
if req.Service != "" && isBuiltInProvider(req.Service) {
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
// Reorder priority to put req.Service first
newPriority := []string{req.Service}
for _, p := range priority {
if p != req.Service {
newPriority = append(newPriority, p)
}
}
priority = newPriority
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
}
var lastErr error var lastErr error
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
@@ -725,18 +789,45 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
enrichedTrack, err := provider.EnrichTrack(trackMeta) enrichedTrack, err := provider.EnrichTrack(trackMeta)
if err == nil && enrichedTrack != nil { if err == nil && enrichedTrack != nil {
// Update request with enriched data
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC { if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC) GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
req.ISRC = enrichedTrack.ISRC req.ISRC = enrichedTrack.ISRC
} }
// Can also update other fields if needed if enrichedTrack.TidalID != "" {
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
req.TidalID = enrichedTrack.TidalID
}
if enrichedTrack.QobuzID != "" {
GoLog("[DownloadWithExtensionFallback] Qobuz ID from Odesli: %s\n", enrichedTrack.QobuzID)
req.QobuzID = enrichedTrack.QobuzID
}
if enrichedTrack.DeezerID != "" {
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
req.DeezerID = enrichedTrack.DeezerID
}
if enrichedTrack.Name != "" { if enrichedTrack.Name != "" {
req.TrackName = enrichedTrack.Name req.TrackName = enrichedTrack.Name
} }
if enrichedTrack.Artists != "" { if enrichedTrack.Artists != "" {
req.ArtistName = 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
}
} }
} }
} }
@@ -747,7 +838,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ext, err := extManager.GetExtension(req.Source) ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() { if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
// Check if this extension wants to skip built-in fallback
skipBuiltIn = ext.Manifest.SkipBuiltInFallback skipBuiltIn = ext.Manifest.SkipBuiltInFallback
provider := NewExtensionProviderWrapper(ext) provider := NewExtensionProviderWrapper(ext)
@@ -758,7 +848,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn) GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
// Build output path
outputPath := buildOutputPath(req) outputPath := buildOutputPath(req)
// Download directly using the track ID from the extension // Download directly using the track ID from the extension
@@ -776,6 +865,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ActualBitDepth: result.BitDepth, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate, ActualSampleRate: result.SampleRate,
Service: req.Source, 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 // If extension has skipMetadataEnrichment, copy metadata
@@ -814,6 +915,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
if err != nil { if err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return &DownloadResponse{
Success: false,
Error: "Download cancelled",
ErrorType: "cancelled",
Service: req.Source,
}, nil
}
lastErr = err lastErr = err
} else if result.ErrorMessage != "" { } else if result.ErrorMessage != "" {
lastErr = fmt.Errorf("%s", result.ErrorMessage) lastErr = fmt.Errorf("%s", result.ErrorMessage)
@@ -851,13 +960,55 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(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 // Use built-in provider
result, err := tryBuiltInProvider(providerID, req) result, err := tryBuiltInProvider(providerID, req)
if err == nil && result.Success { if err == nil && result.Success {
result.Service = providerID 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 return result, nil
} }
if err != nil { if err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return &DownloadResponse{
Success: false,
Error: "Download cancelled",
ErrorType: "cancelled",
Service: providerID,
}, nil
}
lastErr = err lastErr = err
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err) GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
} }
@@ -875,7 +1026,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
provider := NewExtensionProviderWrapper(ext) provider := NewExtensionProviderWrapper(ext)
// Check availability first
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName) availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
if err != nil || !availability.Available { if err != nil || !availability.Available {
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID) GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
@@ -885,12 +1035,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue continue
} }
// Build output path
outputPath := buildOutputPath(req) outputPath := buildOutputPath(req)
// Download
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) { result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
// Update progress
if req.ItemID != "" { if req.ItemID != "" {
SetItemProgress(req.ItemID, float64(percent), 0, 0) SetItemProgress(req.ItemID, float64(percent), 0, 0)
} }
@@ -904,6 +1051,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ActualBitDepth: result.BitDepth, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate, ActualSampleRate: result.SampleRate,
Service: providerID, 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 // If extension has skipMetadataEnrichment and returned metadata, use it
@@ -943,6 +1102,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
if err != nil { if err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return &DownloadResponse{
Success: false,
Error: "Download cancelled",
ErrorType: "cancelled",
Service: providerID,
}, nil
}
lastErr = err lastErr = err
} else if result.ErrorMessage != "" { } else if result.ErrorMessage != "" {
lastErr = fmt.Errorf("%s", result.ErrorMessage) lastErr = fmt.Errorf("%s", result.ErrorMessage)
@@ -1046,6 +1213,9 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: result.TrackNumber, TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber, DiscNumber: result.DiscNumber,
ISRC: result.ISRC, ISRC: result.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}, nil }, nil
} }
@@ -1081,6 +1251,10 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) 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 // Convert options to JSON
optionsJSON, _ := json.Marshal(options) optionsJSON, _ := json.Marshal(options)
@@ -1122,7 +1296,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
tracks = []ExtTrackMetadata{} tracks = []ExtTrackMetadata{}
} }
// Set provider ID on all tracks
for i := range tracks { for i := range tracks {
tracks[i].ProviderID = p.extension.ID tracks[i].ProviderID = p.extension.ID
} }
@@ -1153,6 +1326,10 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) 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(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') { if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') {
@@ -1192,6 +1369,24 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
for i := range handleResult.Tracks { for i := range handleResult.Tracks {
handleResult.Tracks[i].ProviderID = p.extension.ID handleResult.Tracks[i].ProviderID = p.extension.ID
} }
if handleResult.Album != nil {
handleResult.Album.ProviderID = p.extension.ID
for i := range handleResult.Album.Tracks {
handleResult.Album.Tracks[i].ProviderID = p.extension.ID
}
}
if handleResult.Artist != nil {
handleResult.Artist.ProviderID = p.extension.ID
for i := range handleResult.Artist.Albums {
handleResult.Artist.Albums[i].ProviderID = p.extension.ID
for j := range handleResult.Artist.Albums[i].Tracks {
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
}
}
for i := range handleResult.Artist.TopTracks {
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
}
}
return &handleResult, nil return &handleResult, nil
} }
@@ -1216,6 +1411,10 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) 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) sourceJSON, _ := json.Marshal(sourceTrack)
candidatesJSON, _ := json.Marshal(candidates) candidatesJSON, _ := json.Marshal(candidates)
@@ -1279,6 +1478,10 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) 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) metadataJSON, _ := json.Marshal(metadata)
script := fmt.Sprintf(` script := fmt.Sprintf(`
@@ -1425,12 +1628,10 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
for _, provider := range providers { for _, provider := range providers {
hooks := provider.extension.Manifest.GetPostProcessingHooks() hooks := provider.extension.Manifest.GetPostProcessingHooks()
for _, hook := range hooks { for _, hook := range hooks {
// Check if hook is enabled (TODO: check user settings)
if !hook.DefaultEnabled { if !hook.DefaultEnabled {
continue continue
} }
// Check if format is supported
ext := strings.ToLower(filepath.Ext(currentPath)) ext := strings.ToLower(filepath.Ext(currentPath))
if len(hook.SupportedFormats) > 0 { if len(hook.SupportedFormats) > 0 {
supported := false supported := false
+8 -44
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension runtime with sandboxed execution
package gobackend package gobackend
import ( import (
@@ -10,16 +9,13 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
// Default timeout for JS execution (30 seconds)
const DefaultJSTimeout = 30 * time.Second const DefaultJSTimeout = 30 * time.Second
// Global auth state for extensions (stores pending auth codes)
var ( var (
extensionAuthState = make(map[string]*ExtensionAuthState) extensionAuthState = make(map[string]*ExtensionAuthState)
extensionAuthStateMu sync.RWMutex extensionAuthStateMu sync.RWMutex
) )
// ExtensionAuthState holds auth state for an extension
type ExtensionAuthState struct { type ExtensionAuthState struct {
PendingAuthURL string PendingAuthURL string
AuthCode string AuthCode string
@@ -27,39 +23,33 @@ type ExtensionAuthState struct {
RefreshToken string RefreshToken string
ExpiresAt time.Time ExpiresAt time.Time
IsAuthenticated bool IsAuthenticated bool
// PKCE support PKCEVerifier string
PKCEVerifier string PKCEChallenge string
PKCEChallenge string
} }
// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL
type PendingAuthRequest struct { type PendingAuthRequest struct {
ExtensionID string ExtensionID string
AuthURL string AuthURL string
CallbackURL string CallbackURL string
} }
// Global pending auth requests (Flutter polls this)
var ( var (
pendingAuthRequests = make(map[string]*PendingAuthRequest) pendingAuthRequests = make(map[string]*PendingAuthRequest)
pendingAuthRequestsMu sync.RWMutex pendingAuthRequestsMu sync.RWMutex
) )
// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter)
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest { func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
pendingAuthRequestsMu.RLock() pendingAuthRequestsMu.RLock()
defer pendingAuthRequestsMu.RUnlock() defer pendingAuthRequestsMu.RUnlock()
return pendingAuthRequests[extensionID] return pendingAuthRequests[extensionID]
} }
// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL)
func ClearPendingAuthRequest(extensionID string) { func ClearPendingAuthRequest(extensionID string) {
pendingAuthRequestsMu.Lock() pendingAuthRequestsMu.Lock()
defer pendingAuthRequestsMu.Unlock() defer pendingAuthRequestsMu.Unlock()
delete(pendingAuthRequests, extensionID) delete(pendingAuthRequests, extensionID)
} }
// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback)
func SetExtensionAuthCode(extensionID string, authCode string) { func SetExtensionAuthCode(extensionID string, authCode string) {
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock() defer extensionAuthStateMu.Unlock()
@@ -72,7 +62,6 @@ func SetExtensionAuthCode(extensionID string, authCode string) {
state.AuthCode = authCode state.AuthCode = authCode
} }
// SetExtensionTokens sets access/refresh tokens for an extension
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) { func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock() defer extensionAuthStateMu.Unlock()
@@ -88,7 +77,6 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
state.IsAuthenticated = accessToken != "" state.IsAuthenticated = accessToken != ""
} }
// ExtensionRuntime provides sandboxed APIs for extensions
type ExtensionRuntime struct { type ExtensionRuntime struct {
extensionID string extensionID string
manifest *ExtensionManifest manifest *ExtensionManifest
@@ -99,9 +87,7 @@ type ExtensionRuntime struct {
vm *goja.Runtime vm *goja.Runtime
} }
// NewExtensionRuntime creates a new runtime for an extension
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
// Create a cookie jar for this extension
jar, _ := newSimpleCookieJar() jar, _ := newSimpleCookieJar()
runtime := &ExtensionRuntime{ runtime := &ExtensionRuntime{
@@ -113,7 +99,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
vm: ext.VM, vm: ext.VM,
} }
// Create HTTP client with redirect validation to prevent SSRF via open redirect
client := &http.Client{ client := &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
Jar: jar, Jar: jar,
@@ -124,7 +109,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain} return &RedirectBlockedError{Domain: domain}
} }
// Also block redirects to private/local networks (SSRF protection)
if isPrivateIP(domain) { if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true} return &RedirectBlockedError{Domain: domain, IsPrivate: true}
@@ -141,7 +125,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
return runtime return runtime
} }
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
type RedirectBlockedError struct { type RedirectBlockedError struct {
Domain string Domain string
IsPrivate bool IsPrivate bool
@@ -167,10 +150,10 @@ func isPrivateIP(host string) bool {
"172.24.", "172.25.", "172.26.", "172.27.", "172.24.", "172.25.", "172.26.", "172.27.",
"172.28.", "172.29.", "172.30.", "172.31.", "172.28.", "172.29.", "172.30.", "172.31.",
"192.168.", "192.168.",
"169.254.", // Link-local "169.254.",
"::1", // IPv6 localhost "::1",
"fc00:", // IPv6 private "fc00:",
"fe80:", // IPv6 link-local "fe80:",
} }
hostLower := host hostLower := host
@@ -188,7 +171,6 @@ func isPrivateIP(host string) bool {
return false return false
} }
// simpleCookieJar is a simple in-memory cookie jar
type simpleCookieJar struct { type simpleCookieJar struct {
cookies map[string][]*http.Cookie cookies map[string][]*http.Cookie
mu sync.RWMutex mu sync.RWMutex
@@ -213,12 +195,10 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
return j.cookies[u.Host] return j.cookies[u.Host]
} }
// SetSettings updates the runtime settings
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings r.settings = settings
} }
// RegisterAPIs registers all sandboxed APIs to the Goja VM
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
r.vm = vm r.vm = vm
@@ -229,18 +209,16 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
httpObj.Set("put", r.httpPut) httpObj.Set("put", r.httpPut)
httpObj.Set("delete", r.httpDelete) httpObj.Set("delete", r.httpDelete)
httpObj.Set("patch", r.httpPatch) httpObj.Set("patch", r.httpPatch)
httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.) httpObj.Set("request", r.httpRequest)
httpObj.Set("clearCookies", r.httpClearCookies) httpObj.Set("clearCookies", r.httpClearCookies)
vm.Set("http", httpObj) vm.Set("http", httpObj)
// Storage API
storageObj := vm.NewObject() storageObj := vm.NewObject()
storageObj.Set("get", r.storageGet) storageObj.Set("get", r.storageGet)
storageObj.Set("set", r.storageSet) storageObj.Set("set", r.storageSet)
storageObj.Set("remove", r.storageRemove) storageObj.Set("remove", r.storageRemove)
vm.Set("storage", storageObj) vm.Set("storage", storageObj)
// Secure Credentials API (encrypted storage for sensitive data)
credentialsObj := vm.NewObject() credentialsObj := vm.NewObject()
credentialsObj.Set("store", r.credentialsStore) credentialsObj.Set("store", r.credentialsStore)
credentialsObj.Set("get", r.credentialsGet) credentialsObj.Set("get", r.credentialsGet)
@@ -248,7 +226,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
credentialsObj.Set("has", r.credentialsHas) credentialsObj.Set("has", r.credentialsHas)
vm.Set("credentials", credentialsObj) vm.Set("credentials", credentialsObj)
// Auth API (for OAuth and other auth flows)
authObj := vm.NewObject() authObj := vm.NewObject()
authObj.Set("openAuthUrl", r.authOpenUrl) authObj.Set("openAuthUrl", r.authOpenUrl)
authObj.Set("getAuthCode", r.authGetCode) authObj.Set("getAuthCode", r.authGetCode)
@@ -256,7 +233,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
authObj.Set("clearAuth", r.authClear) authObj.Set("clearAuth", r.authClear)
authObj.Set("isAuthenticated", r.authIsAuthenticated) authObj.Set("isAuthenticated", r.authIsAuthenticated)
authObj.Set("getTokens", r.authGetTokens) authObj.Set("getTokens", r.authGetTokens)
// PKCE support
authObj.Set("generatePKCE", r.authGeneratePKCE) authObj.Set("generatePKCE", r.authGeneratePKCE)
authObj.Set("getPKCE", r.authGetPKCE) authObj.Set("getPKCE", r.authGetPKCE)
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE) authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
@@ -275,7 +251,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
fileObj.Set("getSize", r.fileGetSize) fileObj.Set("getSize", r.fileGetSize)
vm.Set("file", fileObj) vm.Set("file", fileObj)
// FFmpeg API (for post-processing)
ffmpegObj := vm.NewObject() ffmpegObj := vm.NewObject()
ffmpegObj.Set("execute", r.ffmpegExecute) ffmpegObj.Set("execute", r.ffmpegExecute)
ffmpegObj.Set("getInfo", r.ffmpegGetInfo) ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
@@ -289,7 +264,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
matchingObj.Set("normalizeString", r.matchingNormalizeString) matchingObj.Set("normalizeString", r.matchingNormalizeString)
vm.Set("matching", matchingObj) vm.Set("matching", matchingObj)
// Utilities
utilsObj := vm.NewObject() utilsObj := vm.NewObject()
utilsObj.Set("base64Encode", r.base64Encode) utilsObj.Set("base64Encode", r.base64Encode)
utilsObj.Set("base64Decode", r.base64Decode) utilsObj.Set("base64Decode", r.base64Decode)
@@ -300,13 +274,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("hmacSHA1", r.hmacSHA1) utilsObj.Set("hmacSHA1", r.hmacSHA1)
utilsObj.Set("parseJSON", r.parseJSON) utilsObj.Set("parseJSON", r.parseJSON)
utilsObj.Set("stringifyJSON", r.stringifyJSON) utilsObj.Set("stringifyJSON", r.stringifyJSON)
// Crypto utilities for developers
utilsObj.Set("encrypt", r.cryptoEncrypt) utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt) utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("generateKey", r.cryptoGenerateKey) utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
vm.Set("utils", utilsObj) vm.Set("utils", utilsObj)
// Log object (already set in extension_manager.go, but we can enhance it)
logObj := vm.NewObject() logObj := vm.NewObject()
logObj.Set("debug", r.logDebug) logObj.Set("debug", r.logDebug)
logObj.Set("info", r.logInfo) logObj.Set("info", r.logInfo)
@@ -314,27 +287,18 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
logObj.Set("error", r.logError) logObj.Set("error", r.logError)
vm.Set("log", logObj) vm.Set("log", logObj)
// Go backend functions
gobackendObj := vm.NewObject() gobackendObj := vm.NewObject()
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper) gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
vm.Set("gobackend", gobackendObj) vm.Set("gobackend", gobackendObj)
// ==================== Browser-like Polyfills ====================
// These make porting browser/Node.js libraries easier
// Global fetch() - Promise-style HTTP API (browser-compatible)
vm.Set("fetch", r.fetchPolyfill) vm.Set("fetch", r.fetchPolyfill)
// Global atob/btoa - Base64 encoding (browser-compatible)
vm.Set("atob", r.atobPolyfill) vm.Set("atob", r.atobPolyfill)
vm.Set("btoa", r.btoaPolyfill) vm.Set("btoa", r.btoaPolyfill)
// TextEncoder/TextDecoder constructors
r.registerTextEncoderDecoder(vm) r.registerTextEncoderDecoder(vm)
// URL class for URL parsing
r.registerURLClass(vm) r.registerURLClass(vm)
// JSON global (browser-compatible)
r.registerJSONGlobal(vm) r.registerJSONGlobal(vm)
} }
+1 -30
View File
@@ -18,7 +18,6 @@ import (
// ==================== Auth API (OAuth Support) ==================== // ==================== Auth API (OAuth Support) ====================
// authOpenUrl requests Flutter to open an OAuth URL
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -33,7 +32,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
callbackURL = call.Arguments[1].String() callbackURL = call.Arguments[1].String()
} }
// Store pending auth request for Flutter to pick up
pendingAuthRequestsMu.Lock() pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID, ExtensionID: r.extensionID,
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
} }
pendingAuthRequestsMu.Unlock() pendingAuthRequestsMu.Unlock()
// Update auth state
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID] state, exists := extensionAuthState[r.extensionID]
if !exists { if !exists {
@@ -50,7 +47,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
extensionAuthState[r.extensionID] = state extensionAuthState[r.extensionID] = state
} }
state.PendingAuthURL = authURL state.PendingAuthURL = authURL
state.AuthCode = "" // Clear any previous auth code state.AuthCode = ""
extensionAuthStateMu.Unlock() extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL) GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, 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 { func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -74,13 +70,11 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(state.AuthCode) return r.vm.ToValue(state.AuthCode)
} }
// authSetCode sets auth code and tokens (can be called by extension after token exchange)
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
// Can accept either just auth code or an object with tokens
arg := call.Arguments[0].Export() arg := call.Arguments[0].Export()
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
@@ -114,7 +108,6 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// authClear clears all auth state for the extension
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
delete(extensionAuthState, r.extensionID) delete(extensionAuthState, r.extensionID)
@@ -138,7 +131,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
// Check if token is expired
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) { if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -146,7 +138,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(state.IsAuthenticated) 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 { func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -182,16 +173,13 @@ func generatePKCEVerifier(length int) (string, error) {
length = 128 length = 128
} }
// Generate random bytes
bytes := make([]byte, length) bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil { if _, err := rand.Read(bytes); err != nil {
return "", err return "", err
} }
// Use base64url encoding without padding (RFC 7636 compliant)
verifier := base64.RawURLEncoding.EncodeToString(bytes) verifier := base64.RawURLEncoding.EncodeToString(bytes)
// Trim to exact length
if len(verifier) > length { if len(verifier) > length {
verifier = verifier[:length] verifier = verifier[:length]
} }
@@ -199,15 +187,12 @@ func generatePKCEVerifier(length int) (string, error) {
return verifier, nil return verifier, nil
} }
// generatePKCEChallenge generates a code challenge from verifier using S256 method
func generatePKCEChallenge(verifier string) string { func generatePKCEChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier)) hash := sha256.Sum256([]byte(verifier))
// Base64url encode without padding (RFC 7636) // Base64url encode without padding (RFC 7636)
return base64.RawURLEncoding.EncodeToString(hash[:]) 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 { func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
// Default length is 64 characters // Default length is 64 characters
length := 64 length := 64
@@ -227,7 +212,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
challenge := generatePKCEChallenge(verifier) challenge := generatePKCEChallenge(verifier)
// Store in auth state for later use
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID] state, exists := extensionAuthState[r.extensionID]
if !exists { if !exists {
@@ -247,7 +231,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 { func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -405,7 +388,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Get stored PKCE verifier
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
state, exists := extensionAuthState[r.extensionID] state, exists := extensionAuthState[r.extensionID]
var verifier string var verifier string
@@ -421,7 +403,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Validate domain
if err := r.validateDomain(tokenURL); err != nil { if err := r.validateDomain(tokenURL); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -429,7 +410,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Build token request body
formData := url.Values{} formData := url.Values{}
formData.Set("grant_type", "authorization_code") formData.Set("grant_type", "authorization_code")
formData.Set("client_id", clientID) formData.Set("client_id", clientID)
@@ -439,14 +419,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
formData.Set("redirect_uri", redirectURI) formData.Set("redirect_uri", redirectURI)
} }
// Add extra params
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok { if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
for k, v := range extraParams { for k, v := range extraParams {
formData.Set(k, fmt.Sprintf("%v", v)) formData.Set(k, fmt.Sprintf("%v", v))
} }
} }
// Make token request
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode())) req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -475,7 +453,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Parse response
var tokenResp map[string]interface{} var tokenResp map[string]interface{}
if err := json.Unmarshal(body, &tokenResp); err != nil { if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -485,7 +462,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Check for error in response
if errMsg, ok := tokenResp["error"].(string); ok { if errMsg, ok := tokenResp["error"].(string); ok {
errDesc, _ := tokenResp["error_description"].(string) errDesc, _ := tokenResp["error_description"].(string)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -495,7 +471,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Extract tokens
accessToken, _ := tokenResp["access_token"].(string) accessToken, _ := tokenResp["access_token"].(string)
refreshToken, _ := tokenResp["refresh_token"].(string) refreshToken, _ := tokenResp["refresh_token"].(string)
expiresIn, _ := tokenResp["expires_in"].(float64) expiresIn, _ := tokenResp["expires_in"].(float64)
@@ -508,7 +483,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Store tokens in auth state
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
state, exists = extensionAuthState[r.extensionID] state, exists = extensionAuthState[r.extensionID]
if !exists { if !exists {
@@ -521,14 +495,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
if expiresIn > 0 { if expiresIn > 0 {
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
} }
// Clear PKCE after successful exchange
state.PKCEVerifier = "" state.PKCEVerifier = ""
state.PKCEChallenge = "" state.PKCEChallenge = ""
extensionAuthStateMu.Unlock() extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID) GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
// Return full token response
result := map[string]interface{}{ result := map[string]interface{}{
"success": true, "success": true,
"access_token": accessToken, "access_token": accessToken,
@@ -538,7 +510,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
if expiresIn > 0 { if expiresIn > 0 {
result["expires_in"] = expiresIn result["expires_in"] = expiresIn
} }
// Include any additional fields from response
if scope, ok := tokenResp["scope"].(string); ok { if scope, ok := tokenResp["scope"].(string); ok {
result["scope"] = scope result["scope"] = scope
} }
-6
View File
@@ -31,14 +31,12 @@ var (
ffmpegCommandID int64 ffmpegCommandID int64
) )
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand { func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
ffmpegCommandsMu.RLock() ffmpegCommandsMu.RLock()
defer ffmpegCommandsMu.RUnlock() defer ffmpegCommandsMu.RUnlock()
return ffmpegCommands[commandID] return ffmpegCommands[commandID]
} }
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) { func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
ffmpegCommandsMu.Lock() ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock() 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) { func ClearFFmpegCommand(commandID string) {
ffmpegCommandsMu.Lock() ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock() defer ffmpegCommandsMu.Unlock()
delete(ffmpegCommands, commandID) delete(ffmpegCommands, commandID)
} }
// ffmpegExecute queues an FFmpeg command for execution by Flutter
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -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 { func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -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 { func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
+1 -43
View File
@@ -15,14 +15,11 @@ import (
// ==================== File API (Sandboxed) ==================== // ==================== File API (Sandboxed) ====================
// List of allowed directories for file operations (set by Go backend for download operations)
var ( var (
allowedDownloadDirs []string allowedDownloadDirs []string
allowedDownloadDirsMu sync.RWMutex allowedDownloadDirsMu sync.RWMutex
) )
// 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) { func SetAllowedDownloadDirs(dirs []string) {
allowedDownloadDirsMu.Lock() allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock() defer allowedDownloadDirsMu.Unlock()
@@ -30,7 +27,6 @@ func SetAllowedDownloadDirs(dirs []string) {
GoLog("[Extension] Allowed download directories set: %v\n", dirs) GoLog("[Extension] Allowed download directories set: %v\n", dirs)
} }
// AddAllowedDownloadDir adds a directory to the allowed list
func AddAllowedDownloadDir(dir string) { func AddAllowedDownloadDir(dir string) {
allowedDownloadDirsMu.Lock() allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock() defer allowedDownloadDirsMu.Unlock()
@@ -40,7 +36,6 @@ func AddAllowedDownloadDir(dir string) {
} }
} }
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
func isPathInAllowedDirs(absPath string) bool { func isPathInAllowedDirs(absPath string) bool {
allowedDownloadDirsMu.RLock() allowedDownloadDirsMu.RLock()
defer allowedDownloadDirsMu.RUnlock() defer allowedDownloadDirsMu.RUnlock()
@@ -53,45 +48,34 @@ func isPathInAllowedDirs(absPath string) bool {
return false return false
} }
// validatePath checks if the path is within the extension's sandbox
// Security: Absolute paths are BLOCKED unless they're in allowed download directories
// Extensions should use relative paths for their own data storage
func (r *ExtensionRuntime) validatePath(path string) (string, error) { func (r *ExtensionRuntime) validatePath(path string) (string, error) {
// Check if extension has file permission // Check if extension has file permission
if !r.manifest.Permissions.File { if !r.manifest.Permissions.File {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission") return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
} }
// Clean and resolve the path
cleanPath := filepath.Clean(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) { if filepath.IsAbs(cleanPath) {
absPath, err := filepath.Abs(cleanPath) absPath, err := filepath.Abs(cleanPath)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid path: %w", err) return "", fmt.Errorf("invalid path: %w", err)
} }
// Check if path is in allowed download directories
if isPathInAllowedDirs(absPath) { if isPathInAllowedDirs(absPath) {
return absPath, nil 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") 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) fullPath := filepath.Join(r.dataDir, cleanPath)
// Resolve to absolute path
absPath, err := filepath.Abs(fullPath) absPath, err := filepath.Abs(fullPath)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid path: %w", err) return "", fmt.Errorf("invalid path: %w", err)
} }
// Ensure path is within data directory (prevent path traversal)
absDataDir, _ := filepath.Abs(r.dataDir) absDataDir, _ := filepath.Abs(r.dataDir)
if !strings.HasPrefix(absPath, absDataDir) { if !strings.HasPrefix(absPath, absDataDir) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path) return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
@@ -100,8 +84,6 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return absPath, nil 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 { func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -113,7 +95,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
outputPath := call.Arguments[1].String() outputPath := call.Arguments[1].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -121,7 +102,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Validate output path (allows absolute paths for download queue)
fullPath, err := r.validatePath(outputPath) fullPath, err := r.validatePath(outputPath)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -130,20 +110,17 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Get options if provided
var onProgress goja.Callable var onProgress goja.Callable
var headers map[string]string var headers map[string]string
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
optionsObj := call.Arguments[2].Export() optionsObj := call.Arguments[2].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok { if opts, ok := optionsObj.(map[string]interface{}); ok {
// Extract headers
if h, ok := opts["headers"].(map[string]interface{}); ok { if h, ok := opts["headers"].(map[string]interface{}); ok {
headers = make(map[string]string) headers = make(map[string]string)
for k, v := range h { for k, v := range h {
headers[k] = fmt.Sprintf("%v", v) headers[k] = fmt.Sprintf("%v", v)
} }
} }
// Extract onProgress callback
if progressVal, ok := opts["onProgress"]; ok { if progressVal, ok := opts["onProgress"]; ok {
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok { if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
onProgress = callable onProgress = callable
@@ -152,7 +129,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
} }
} }
// Create directory if needed
dir := filepath.Dir(fullPath) dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -161,7 +137,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Create HTTP request
req, err := http.NewRequest("GET", urlStr, nil) req, err := http.NewRequest("GET", urlStr, nil)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -170,7 +145,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Set headers
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
@@ -178,7 +152,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
} }
// Download file
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -195,7 +168,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Create output file
out, err := os.Create(fullPath) out, err := os.Create(fullPath)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -205,12 +177,10 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
} }
defer out.Close() defer out.Close()
// Get content length for progress
contentLength := resp.ContentLength contentLength := resp.ContentLength
// Copy content with progress reporting
var written int64 var written int64
buf := make([]byte, 32*1024) // 32KB buffer buf := make([]byte, 32*1024)
for { for {
nr, er := resp.Body.Read(buf) nr, er := resp.Body.Read(buf)
if nr > 0 { if nr > 0 {
@@ -235,7 +205,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Report progress
if onProgress != nil && contentLength > 0 { if onProgress != nil && contentLength > 0 {
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength)) _, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
} }
@@ -260,7 +229,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 { func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -276,7 +244,6 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(err == nil) return r.vm.ToValue(err == nil)
} }
// fileDelete deletes a file in the sandbox
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -306,7 +273,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 { func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -338,7 +304,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 { func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -380,7 +345,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 { func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -408,7 +372,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
// Read source file
data, err := os.ReadFile(fullSrc) data, err := os.ReadFile(fullSrc)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -417,7 +380,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
// Create destination directory if needed
dir := filepath.Dir(fullDst) dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -426,7 +388,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
// Write to destination
if err := os.WriteFile(fullDst, data, 0644); err != nil { if err := os.WriteFile(fullDst, data, 0644); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -440,7 +401,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 { func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -468,7 +428,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
}) })
} }
// Create destination directory if needed
dir := filepath.Dir(fullDst) dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -490,7 +449,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 { func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
+9 -60
View File
@@ -14,14 +14,12 @@ import (
// ==================== HTTP API (Sandboxed) ==================== // ==================== HTTP API (Sandboxed) ====================
// HTTPResponse represents the response from an HTTP request
type HTTPResponse struct { type HTTPResponse struct {
StatusCode int `json:"statusCode"` StatusCode int `json:"statusCode"`
Body string `json:"body"` Body string `json:"body"`
Headers map[string]string `json:"headers"` Headers map[string]string `json:"headers"`
} }
// validateDomain checks if the domain is allowed by the extension's permissions
func (r *ExtensionRuntime) validateDomain(urlStr string) error { func (r *ExtensionRuntime) validateDomain(urlStr string) error {
parsed, err := url.Parse(urlStr) parsed, err := url.Parse(urlStr)
if err != nil { if err != nil {
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
return nil return nil
} }
// httpGet performs a GET request (sandboxed)
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -52,7 +49,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -60,7 +56,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}) })
} }
// Get headers if provided
headers := make(map[string]string) headers := make(map[string]string)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export() headersObj := call.Arguments[1].Export()
@@ -71,7 +66,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
} }
} }
// Create request
req, err := http.NewRequest("GET", urlStr, nil) req, err := http.NewRequest("GET", urlStr, nil)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -79,16 +73,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}) })
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
// Only set default User-Agent if not provided by extension
if req.Header.Get("User-Agent") == "" { if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -97,7 +89,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -105,26 +96,24 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}) })
} }
// Extract response headers - return all values as arrays for multi-value headers (cookies, etc.)
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
respHeaders[k] = v[0] respHeaders[k] = v[0]
} else { } else {
respHeaders[k] = v // Return as array if multiple values respHeaders[k] = v
} }
} }
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
}) })
} }
// httpPost performs a POST request (sandboxed)
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -134,7 +123,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -142,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}) })
} }
// Get body if provided - support both string and object
var bodyStr string var bodyStr string
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyArg := call.Arguments[1].Export() bodyArg := call.Arguments[1].Export()
@@ -150,7 +137,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
case string: case string:
bodyStr = v bodyStr = v
case map[string]interface{}, []interface{}: case map[string]interface{}, []interface{}:
// Auto-stringify objects and arrays to JSON
jsonBytes, err := json.Marshal(v) jsonBytes, err := json.Marshal(v)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -159,12 +145,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
} }
bodyStr = string(jsonBytes) bodyStr = string(jsonBytes)
default: default:
// Fallback to string conversion
bodyStr = call.Arguments[1].String() bodyStr = call.Arguments[1].String()
} }
} }
// Get headers if provided
headers := make(map[string]string) headers := make(map[string]string)
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
headersObj := call.Arguments[2].Export() headersObj := call.Arguments[2].Export()
@@ -175,7 +159,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
} }
} }
// Create request
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr)) req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -183,11 +166,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}) })
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
// Only set defaults if not provided by extension
if req.Header.Get("User-Agent") == "" { if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
} }
@@ -195,7 +177,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -204,7 +185,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -212,27 +192,24 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}) })
} }
// Extract response headers - return all values as arrays for multi-value headers
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
respHeaders[k] = v[0] respHeaders[k] = v[0]
} else { } else {
respHeaders[k] = v // Return as array if multiple values respHeaders[k] = v
} }
} }
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
}) })
} }
// 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 { func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -242,7 +219,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -250,27 +226,22 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}) })
} }
// Default options
method := "GET" method := "GET"
var bodyStr string var bodyStr string
headers := make(map[string]string) headers := make(map[string]string)
// Parse options if provided
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
optionsObj := call.Arguments[1].Export() optionsObj := call.Arguments[1].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok { if opts, ok := optionsObj.(map[string]interface{}); ok {
// Get method
if m, ok := opts["method"].(string); ok { if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m) method = strings.ToUpper(m)
} }
// Get body - support both string and object
if bodyArg, ok := opts["body"]; ok && bodyArg != nil { if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
switch v := bodyArg.(type) { switch v := bodyArg.(type) {
case string: case string:
bodyStr = v bodyStr = v
case map[string]interface{}, []interface{}: case map[string]interface{}, []interface{}:
// Auto-stringify objects and arrays to JSON
jsonBytes, err := json.Marshal(v) jsonBytes, err := json.Marshal(v)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -283,7 +254,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
} }
} }
// Get headers
if h, ok := opts["headers"].(map[string]interface{}); ok { if h, ok := opts["headers"].(map[string]interface{}); ok {
for k, v := range h { for k, v := range h {
headers[k] = fmt.Sprintf("%v", v) headers[k] = fmt.Sprintf("%v", v)
@@ -292,7 +262,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
} }
} }
// Create request
var reqBody io.Reader var reqBody io.Reader
if bodyStr != "" { if bodyStr != "" {
reqBody = strings.NewReader(bodyStr) reqBody = strings.NewReader(bodyStr)
@@ -305,11 +274,10 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}) })
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
// Only set defaults if not provided by extension
if req.Header.Get("User-Agent") == "" { if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
} }
@@ -317,7 +285,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -326,7 +293,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -334,43 +300,36 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}) })
} }
// Extract response headers - return all values as arrays for multi-value headers
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
respHeaders[k] = v[0] respHeaders[k] = v[0]
} else { } else {
respHeaders[k] = v // Return as array if multiple values respHeaders[k] = v
} }
} }
// Return response with helper properties
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
}) })
} }
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call) return r.httpMethodShortcut("PUT", call)
} }
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call) return r.httpMethodShortcut("DELETE", call)
} }
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call) return r.httpMethodShortcut("PATCH", call)
} }
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -380,7 +339,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -391,9 +349,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
var bodyStr string var bodyStr string
headers := make(map[string]string) headers := make(map[string]string)
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
if method == "DELETE" { if method == "DELETE" {
// http.delete(url, headers)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export() headersObj := call.Arguments[1].Export()
if h, ok := headersObj.(map[string]interface{}); ok { if h, ok := headersObj.(map[string]interface{}); ok {
@@ -403,7 +359,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
} }
} }
} else { } else {
// http.put(url, body, headers) / http.patch(url, body, headers)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyArg := call.Arguments[1].Export() bodyArg := call.Arguments[1].Export()
switch v := bodyArg.(type) { switch v := bodyArg.(type) {
@@ -432,7 +387,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
} }
} }
// Create request
var reqBody io.Reader var reqBody io.Reader
if bodyStr != "" { if bodyStr != "" {
reqBody = strings.NewReader(bodyStr) reqBody = strings.NewReader(bodyStr)
@@ -445,7 +399,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}) })
} }
// Set headers - user headers first
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
@@ -456,7 +409,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
// Execute request
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -465,7 +417,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -473,7 +424,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}) })
} }
// Extract response headers
respHeaders := make(map[string]interface{}) respHeaders := make(map[string]interface{})
for k, v := range resp.Header { for k, v := range resp.Header {
if len(v) == 1 { if len(v) == 1 {
@@ -492,7 +442,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 { func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
if jar, ok := r.cookieJar.(*simpleCookieJar); ok { if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
jar.mu.Lock() jar.mu.Lock()
+1 -33
View File
@@ -17,12 +17,10 @@ import (
// ==================== Storage API ==================== // ==================== Storage API ====================
// getStoragePath returns the path to the extension's storage file
func (r *ExtensionRuntime) getStoragePath() string { func (r *ExtensionRuntime) getStoragePath() string {
return filepath.Join(r.dataDir, "storage.json") return filepath.Join(r.dataDir, "storage.json")
} }
// loadStorage loads the storage data from disk
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) { func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
storagePath := r.getStoragePath() storagePath := r.getStoragePath()
data, err := os.ReadFile(storagePath) data, err := os.ReadFile(storagePath)
@@ -41,7 +39,6 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
return storage, nil return storage, nil
} }
// saveStorage saves the storage data to disk
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error { func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
storagePath := r.getStoragePath() storagePath := r.getStoragePath()
data, err := json.MarshalIndent(storage, "", " ") data, err := json.MarshalIndent(storage, "", " ")
@@ -52,7 +49,6 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
return os.WriteFile(storagePath, data, 0644) return os.WriteFile(storagePath, data, 0644)
} }
// storageGet retrieves a value from storage
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
@@ -68,7 +64,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
value, exists := storage[key] value, exists := storage[key]
if !exists { if !exists {
// Return default value if provided
if len(call.Arguments) > 1 { if len(call.Arguments) > 1 {
return call.Arguments[1] return call.Arguments[1]
} }
@@ -78,7 +73,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value) return r.vm.ToValue(value)
} }
// storageSet stores a value in storage
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -103,7 +97,6 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// storageRemove removes a value from storage
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -127,35 +120,27 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// ==================== Credentials API (Encrypted Storage) ====================
// getCredentialsPath returns the path to the extension's encrypted credentials file
func (r *ExtensionRuntime) getCredentialsPath() string { func (r *ExtensionRuntime) getCredentialsPath() string {
return filepath.Join(r.dataDir, ".credentials.enc") return filepath.Join(r.dataDir, ".credentials.enc")
} }
// getSaltPath returns the path to the extension's encryption salt file
func (r *ExtensionRuntime) getSaltPath() string { func (r *ExtensionRuntime) getSaltPath() string {
return filepath.Join(r.dataDir, ".cred_salt") return filepath.Join(r.dataDir, ".cred_salt")
} }
// getOrCreateSalt gets existing salt or creates a new random one
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) { func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath() saltPath := r.getSaltPath()
// Try to read existing salt
salt, err := os.ReadFile(saltPath) salt, err := os.ReadFile(saltPath)
if err == nil && len(salt) == 32 { if err == nil && len(salt) == 32 {
return salt, nil return salt, nil
} }
// Generate new random salt (32 bytes)
salt = make([]byte, 32) salt = make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil { if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err) return nil, fmt.Errorf("failed to generate salt: %w", err)
} }
// Save salt to file
if err := os.WriteFile(saltPath, salt, 0600); err != nil { if err := os.WriteFile(saltPath, salt, 0600); err != nil {
return nil, fmt.Errorf("failed to save salt: %w", err) return nil, fmt.Errorf("failed to save salt: %w", err)
} }
@@ -163,22 +148,17 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
return salt, nil return salt, nil
} }
// getEncryptionKey derives an encryption key from extension ID + random salt
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) { func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
// Get or create per-installation random salt
salt, err := r.getOrCreateSalt() salt, err := r.getOrCreateSalt()
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Combine extension ID + random salt for key derivation
// This makes each installation unique, preventing mass decryption attacks
combined := append([]byte(r.extensionID), salt...) combined := append([]byte(r.extensionID), salt...)
hash := sha256.Sum256(combined) hash := sha256.Sum256(combined)
return hash[:], nil return hash[:], nil
} }
// loadCredentials loads and decrypts credentials from disk
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
credPath := r.getCredentialsPath() credPath := r.getCredentialsPath()
data, err := os.ReadFile(credPath) data, err := os.ReadFile(credPath)
@@ -189,7 +169,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
return nil, err return nil, err
} }
// Decrypt the data
key, err := r.getEncryptionKey() key, err := r.getEncryptionKey()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get encryption key: %w", err) return nil, fmt.Errorf("failed to get encryption key: %w", err)
@@ -207,14 +186,12 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
return creds, nil return creds, nil
} }
// saveCredentials encrypts and saves credentials to disk
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
data, err := json.Marshal(creds) data, err := json.Marshal(creds)
if err != nil { if err != nil {
return err return err
} }
// Encrypt the data
key, err := r.getEncryptionKey() key, err := r.getEncryptionKey()
if err != nil { if err != nil {
return fmt.Errorf("failed to get encryption key: %w", err) return fmt.Errorf("failed to get encryption key: %w", err)
@@ -225,10 +202,9 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
} }
credPath := r.getCredentialsPath() credPath := r.getCredentialsPath()
return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions return os.WriteFile(credPath, encrypted, 0600)
} }
// credentialsStore stores an encrypted credential
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -264,7 +240,6 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
}) })
} }
// credentialsGet retrieves a decrypted credential
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
@@ -280,7 +255,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
value, exists := creds[key] value, exists := creds[key]
if !exists { if !exists {
// Return default value if provided
if len(call.Arguments) > 1 { if len(call.Arguments) > 1 {
return call.Arguments[1] return call.Arguments[1]
} }
@@ -290,7 +264,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value) return r.vm.ToValue(value)
} }
// credentialsRemove removes a credential
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -314,7 +287,6 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// credentialsHas checks if a credential exists
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
@@ -331,9 +303,6 @@ func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(exists) return r.vm.ToValue(exists)
} }
// ==================== Crypto Utilities ====================
// encryptAES encrypts data using AES-GCM
func encryptAES(plaintext []byte, key []byte) ([]byte, error) { func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
@@ -354,7 +323,6 @@ func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
return ciphertext, nil return ciphertext, nil
} }
// decryptAES decrypts data using AES-GCM
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) { func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
+25 -30
View File
@@ -12,13 +12,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
// ==================== Utility Functions ==================== // ==================== Utility Functions ====================
// base64Encode encodes a string to base64
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -27,7 +27,6 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
} }
// base64Decode decodes a base64 string
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -40,7 +39,6 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded)) return r.vm.ToValue(string(decoded))
} }
// md5Hash computes MD5 hash of a string
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -50,7 +48,6 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:])) return r.vm.ToValue(hex.EncodeToString(hash[:]))
} }
// sha256Hash computes SHA256 hash of a string
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -60,7 +57,6 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:])) return r.vm.ToValue(hex.EncodeToString(hash[:]))
} }
// hmacSHA256 computes HMAC-SHA256 of a message with a key
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -73,7 +69,6 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil))) return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
} }
// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -86,15 +81,11 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil))) return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
} }
// hmacSHA1 computes HMAC-SHA1 of a message with a key (for TOTP)
// Arguments: message (string or array of bytes), key (string or array of bytes)
// Returns: array of bytes (for TOTP dynamic truncation)
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue([]byte{}) return r.vm.ToValue([]byte{})
} }
// Get key - can be string or array of bytes
var keyBytes []byte var keyBytes []byte
keyArg := call.Arguments[0].Export() keyArg := call.Arguments[0].Export()
switch k := keyArg.(type) { switch k := keyArg.(type) {
@@ -113,7 +104,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue([]byte{}) return r.vm.ToValue([]byte{})
} }
// Get message - can be string or array of bytes
var msgBytes []byte var msgBytes []byte
msgArg := call.Arguments[1].Export() msgArg := call.Arguments[1].Export()
switch m := msgArg.(type) { switch m := msgArg.(type) {
@@ -136,7 +126,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
mac.Write(msgBytes) mac.Write(msgBytes)
result := mac.Sum(nil) result := mac.Sum(nil)
// Convert to array of numbers for JavaScript
jsArray := make([]interface{}, len(result)) jsArray := make([]interface{}, len(result))
for i, b := range result { for i, b := range result {
jsArray[i] = int(b) jsArray[i] = int(b)
@@ -144,7 +133,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(jsArray) return r.vm.ToValue(jsArray)
} }
// parseJSON parses a JSON string
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
@@ -160,7 +148,6 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result) return r.vm.ToValue(result)
} }
// stringifyJSON converts a value to JSON string
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -176,9 +163,6 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(data)) return r.vm.ToValue(string(data))
} }
// ==================== Crypto Utilities for Extensions ====================
// cryptoEncrypt encrypts a string using AES-GCM (for extension use)
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -190,7 +174,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
plaintext := call.Arguments[0].String() plaintext := call.Arguments[0].String()
keyStr := call.Arguments[1].String() keyStr := call.Arguments[1].String()
// Derive 32-byte key from provided key string
keyHash := sha256.Sum256([]byte(keyStr)) keyHash := sha256.Sum256([]byte(keyStr))
encrypted, err := encryptAES([]byte(plaintext), keyHash[:]) encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
@@ -207,7 +190,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
}) })
} }
// cryptoDecrypt decrypts a string using AES-GCM (for extension use)
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -227,14 +209,13 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
}) })
} }
// Derive 32-byte key from provided key string
keyHash := sha256.Sum256([]byte(keyStr)) keyHash := sha256.Sum256([]byte(keyStr))
decrypted, err := decryptAES(ciphertext, keyHash[:]) decrypted, err := decryptAES(ciphertext, keyHash[:])
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
"error": err.Error(), "error": "invalid base64 ciphertext",
}) })
} }
@@ -244,9 +225,8 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
}) })
} }
// cryptoGenerateKey generates a random encryption key
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
length := 32 // Default 256-bit key length := 32
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok { if l, ok := call.Arguments[0].Export().(float64); ok {
length = int(l) length = int(l)
@@ -268,7 +248,9 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
}) })
} }
// ==================== Logging Functions ==================== func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent())
}
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments) msg := r.formatLogArgs(call.Arguments)
@@ -302,8 +284,6 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
return strings.Join(parts, " ") return strings.Join(parts, " ")
} }
// ==================== Go Backend Wrappers ====================
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
@@ -312,7 +292,6 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
return r.vm.ToValue(sanitizeFilename(input)) return r.vm.ToValue(sanitizeFilename(input))
} }
// RegisterGoBackendAPIs adds more Go backend functions to the VM
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) { func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
gobackendObj := vm.Get("gobackend") gobackendObj := vm.Get("gobackend")
if gobackendObj == nil || goja.IsUndefined(gobackendObj) { if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
@@ -322,7 +301,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
obj := gobackendObj.(*goja.Object) obj := gobackendObj.(*goja.Object)
// Expose sanitizeFilename
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value { obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return vm.ToValue("") return vm.ToValue("")
@@ -330,7 +308,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(sanitizeFilename(call.Arguments[0].String())) return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
}) })
// Expose getAudioQuality
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value { obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return vm.ToValue(map[string]interface{}{ return vm.ToValue(map[string]interface{}{
@@ -353,7 +330,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
}) })
}) })
// Expose buildFilename
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value { obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return vm.ToValue("") return vm.ToValue("")
@@ -369,4 +345,23 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(buildFilenameFromTemplate(template, metadata)) return vm.ToValue(buildFilenameFromTemplate(template, metadata))
}) })
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(),
})
})
} }
-19
View File
@@ -9,7 +9,6 @@ import (
"sync" "sync"
) )
// ExtensionSettingsStore manages settings for all extensions
type ExtensionSettingsStore struct { type ExtensionSettingsStore struct {
mu sync.RWMutex mu sync.RWMutex
dataDir string dataDir string
@@ -22,7 +21,6 @@ var (
globalSettingsStoreOnce sync.Once globalSettingsStoreOnce sync.Once
) )
// GetExtensionSettingsStore returns the global settings store
func GetExtensionSettingsStore() *ExtensionSettingsStore { func GetExtensionSettingsStore() *ExtensionSettingsStore {
globalSettingsStoreOnce.Do(func() { globalSettingsStoreOnce.Do(func() {
globalSettingsStore = &ExtensionSettingsStore{ globalSettingsStore = &ExtensionSettingsStore{
@@ -32,7 +30,6 @@ func GetExtensionSettingsStore() *ExtensionSettingsStore {
return globalSettingsStore return globalSettingsStore
} }
// SetDataDir sets the data directory for settings storage
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error { func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -42,16 +39,13 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
return fmt.Errorf("failed to create settings directory: %w", err) return fmt.Errorf("failed to create settings directory: %w", err)
} }
// Load all existing settings
return s.loadAllSettings() return s.loadAllSettings()
} }
// getSettingsPath returns the path to an extension's settings file
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string { func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
return filepath.Join(s.dataDir, extensionID, "settings.json") return filepath.Join(s.dataDir, extensionID, "settings.json")
} }
// loadAllSettings loads settings for all extensions from disk
func (s *ExtensionSettingsStore) loadAllSettings() error { func (s *ExtensionSettingsStore) loadAllSettings() error {
entries, err := os.ReadDir(s.dataDir) entries, err := os.ReadDir(s.dataDir)
if err != nil { if err != nil {
@@ -76,7 +70,6 @@ func (s *ExtensionSettingsStore) loadAllSettings() error {
return nil return nil
} }
// loadSettings loads settings for a specific extension
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) { func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
settingsPath := s.getSettingsPath(extensionID) settingsPath := s.getSettingsPath(extensionID)
data, err := os.ReadFile(settingsPath) data, err := os.ReadFile(settingsPath)
@@ -95,11 +88,9 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
return settings, nil return settings, nil
} }
// saveSettings saves settings for a specific extension
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error { func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
settingsPath := s.getSettingsPath(extensionID) settingsPath := s.getSettingsPath(extensionID)
// Create directory if needed
dir := filepath.Dir(settingsPath) dir := filepath.Dir(settingsPath)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return err return err
@@ -113,8 +104,6 @@ func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[s
return os.WriteFile(settingsPath, data, 0644) return os.WriteFile(settingsPath, data, 0644)
} }
// Get retrieves a setting value for an extension
// Returns error if extension or key not found (gomobile compatible)
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) { func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -131,7 +120,6 @@ func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, erro
return value, nil return value, nil
} }
// GetAll retrieves all settings for an extension
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} { func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -149,7 +137,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
return result return result
} }
// Set stores a setting value for an extension
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error { func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -160,11 +147,9 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
s.settings[extensionID][key] = value s.settings[extensionID][key] = value
// Persist to disk
return s.saveSettings(extensionID, s.settings[extensionID]) return s.saveSettings(extensionID, s.settings[extensionID])
} }
// SetAll stores all settings for an extension
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error { func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -175,7 +160,6 @@ func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]
return s.saveSettings(extensionID, settings) return s.saveSettings(extensionID, settings)
} }
// Remove removes a setting for an extension
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error { func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -191,14 +175,12 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
return s.saveSettings(extensionID, extSettings) return s.saveSettings(extensionID, extSettings)
} }
// RemoveAll removes all settings for an extension
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error { func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
delete(s.settings, extensionID) delete(s.settings, extensionID)
// Remove settings file
settingsPath := s.getSettingsPath(extensionID) settingsPath := s.getSettingsPath(extensionID)
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) { if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
return err return err
@@ -207,7 +189,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
return nil return nil
} }
// GetAllExtensionSettings returns settings for all extensions as JSON
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) { func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
+17 -37
View File
@@ -20,29 +20,26 @@ const (
CategoryIntegration = "integration" CategoryIntegration = "integration"
) )
// StoreExtension represents an extension in the store
type StoreExtension struct { type StoreExtension struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"` Version string `json:"version"`
Author string `json:"author"` Author string `json:"author"`
Description string `json:"description"` Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"` IconURL string `json:"icon_url,omitempty"`
Category string `json:"category"` Category string `json:"category"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Downloads int `json:"downloads"` Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"` MinAppVersion string `json:"min_app_version,omitempty"`
// Alternative camelCase fields (for flexibility) DisplayNameAlt string `json:"displayName,omitempty"`
DisplayNameAlt string `json:"displayName,omitempty"` DownloadURLAlt string `json:"downloadUrl,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"` IconURLAlt string `json:"iconUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"` MinAppVersionAlt string `json:"minAppVersion,omitempty"`
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
} }
// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict)
func (e *StoreExtension) getDisplayName() string { func (e *StoreExtension) getDisplayName() string {
if e.DisplayName != "" { if e.DisplayName != "" {
return e.DisplayName return e.DisplayName
@@ -53,7 +50,6 @@ func (e *StoreExtension) getDisplayName() string {
return e.Name return e.Name
} }
// getDownloadURL returns download URL from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getDownloadURL() string { func (e *StoreExtension) getDownloadURL() string {
if e.DownloadURL != "" { if e.DownloadURL != "" {
return e.DownloadURL return e.DownloadURL
@@ -61,7 +57,6 @@ func (e *StoreExtension) getDownloadURL() string {
return e.DownloadURLAlt return e.DownloadURLAlt
} }
// getIconURL returns icon URL from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getIconURL() string { func (e *StoreExtension) getIconURL() string {
if e.IconURL != "" { if e.IconURL != "" {
return e.IconURL return e.IconURL
@@ -69,7 +64,6 @@ func (e *StoreExtension) getIconURL() string {
return e.IconURLAlt return e.IconURLAlt
} }
// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getMinAppVersion() string { func (e *StoreExtension) getMinAppVersion() string {
if e.MinAppVersion != "" { if e.MinAppVersion != "" {
return e.MinAppVersion return e.MinAppVersion
@@ -77,7 +71,6 @@ func (e *StoreExtension) getMinAppVersion() string {
return e.MinAppVersionAlt return e.MinAppVersionAlt
} }
// StoreRegistry represents the extension registry
type StoreRegistry struct { type StoreRegistry struct {
Version int `json:"version"` Version int `json:"version"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
@@ -104,7 +97,6 @@ type StoreExtensionResponse struct {
HasUpdate bool `json:"has_update"` HasUpdate bool `json:"has_update"`
} }
// ToResponse converts StoreExtension to normalized response
func (e *StoreExtension) ToResponse() StoreExtensionResponse { func (e *StoreExtension) ToResponse() StoreExtensionResponse {
return StoreExtensionResponse{ return StoreExtensionResponse{
ID: e.ID, ID: e.ID,
@@ -123,7 +115,6 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
} }
} }
// ExtensionStore manages the extension store
type ExtensionStore struct { type ExtensionStore struct {
registryURL string registryURL string
cacheDir string cacheDir string
@@ -144,7 +135,6 @@ const (
cacheFileName = "store_cache.json" cacheFileName = "store_cache.json"
) )
// InitExtensionStore initializes the extension store
func InitExtensionStore(cacheDir string) *ExtensionStore { func InitExtensionStore(cacheDir string) *ExtensionStore {
extensionStoreMu.Lock() extensionStoreMu.Lock()
defer extensionStoreMu.Unlock() defer extensionStoreMu.Unlock()
@@ -161,14 +151,12 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
return extensionStore return extensionStore
} }
// GetExtensionStore returns the singleton store instance
func GetExtensionStore() *ExtensionStore { func GetExtensionStore() *ExtensionStore {
extensionStoreMu.Lock() extensionStoreMu.Lock()
defer extensionStoreMu.Unlock() defer extensionStoreMu.Unlock()
return extensionStore return extensionStore
} }
// loadDiskCache loads cached registry from disk
func (s *ExtensionStore) loadDiskCache() { func (s *ExtensionStore) loadDiskCache() {
if s.cacheDir == "" { if s.cacheDir == "" {
return return
@@ -194,7 +182,6 @@ func (s *ExtensionStore) loadDiskCache() {
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions)) LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
} }
// saveDiskCache saves registry to disk cache
func (s *ExtensionStore) saveDiskCache() { func (s *ExtensionStore) saveDiskCache() {
if s.cacheDir == "" || s.cache == nil { if s.cacheDir == "" || s.cache == nil {
return return
@@ -217,7 +204,6 @@ func (s *ExtensionStore) saveDiskCache() {
os.WriteFile(cachePath, data, 0644) os.WriteFile(cachePath, data, 0644)
} }
// FetchRegistry fetches the extension registry from GitHub
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) { func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
@@ -268,7 +254,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return &registry, nil return &registry, nil
} }
// GetExtensionsWithStatus returns extensions with installation status
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) { func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(false) registry, err := s.FetchRegistry(false)
if err != nil { if err != nil {
@@ -300,7 +285,6 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
return result, nil return result, nil
} }
// DownloadExtension downloads an extension package to the specified path
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error { func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false) registry, err := s.FetchRegistry(false)
if err != nil { if err != nil {
@@ -332,7 +316,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return fmt.Errorf("download returned HTTP %d", resp.StatusCode) return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
} }
// Create destination file
out, err := os.Create(destPath) out, err := os.Create(destPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create file: %w", err) return fmt.Errorf("failed to create file: %w", err)
@@ -349,7 +332,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return nil return nil
} }
// GetCategories returns all available categories
func (s *ExtensionStore) GetCategories() []string { func (s *ExtensionStore) GetCategories() []string {
return []string{ return []string{
CategoryMetadata, CategoryMetadata,
@@ -360,7 +342,6 @@ func (s *ExtensionStore) GetCategories() []string {
} }
} }
// SearchExtensions searches extensions by query
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) { func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus() extensions, err := s.GetExtensionsWithStatus()
if err != nil { if err != nil {
@@ -406,7 +387,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
return result, nil return result, nil
} }
// ClearCache clears the in-memory and disk cache
func (s *ExtensionStore) ClearCache() { func (s *ExtensionStore) ClearCache() {
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
-11
View File
@@ -6,28 +6,21 @@ import (
"strings" "strings"
) )
// Invalid filename characters for Android/Windows/Linux
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`) var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(filename string) string { func sanitizeFilename(filename string) string {
// Replace invalid characters with underscore
sanitized := invalidChars.ReplaceAllString(filename, "_") sanitized := invalidChars.ReplaceAllString(filename, "_")
// Remove leading/trailing spaces and dots
sanitized = strings.TrimSpace(sanitized) sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ".") sanitized = strings.Trim(sanitized, ".")
// Collapse multiple underscores
multiUnderscore := regexp.MustCompile(`_+`) multiUnderscore := regexp.MustCompile(`_+`)
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_") sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
// Limit length (Android has 255 byte limit for filenames)
if len(sanitized) > 200 { if len(sanitized) > 200 {
sanitized = sanitized[:200] sanitized = sanitized[:200]
} }
// Ensure not empty
if sanitized == "" { if sanitized == "" {
sanitized = "untitled" sanitized = "untitled"
} }
@@ -35,7 +28,6 @@ func sanitizeFilename(filename string) string {
return sanitized return sanitized
} }
// buildFilenameFromTemplate builds a filename from template and metadata
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string { func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
if template == "" { if template == "" {
template = "{artist} - {title}" template = "{artist} - {title}"
@@ -43,7 +35,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
result := template result := template
// Replace placeholders
placeholders := map[string]string{ placeholders := map[string]string{
"{title}": getString(metadata, "title"), "{title}": getString(metadata, "title"),
"{artist}": getString(metadata, "artist"), "{artist}": getString(metadata, "artist"),
@@ -63,7 +54,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
func getString(m map[string]interface{}, key string) string { func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok { if v, ok := m[key]; ok {
if s, ok := v.(string); ok { if s, ok := v.(string); ok {
// Trim leading/trailing whitespace to prevent filename issues
return strings.TrimSpace(s) return strings.TrimSpace(s)
} }
} }
@@ -98,7 +88,6 @@ func formatDiscNumber(n int) string {
return fmt.Sprintf("%d", n) return fmt.Sprintf("%d", n)
} }
// extractYear extracts year from date string (YYYY-MM-DD or YYYY)
func extractYear(date string) string { func extractYear(date string) string {
if len(date) >= 4 { if len(date) >= 4 {
return date[:4] return date[:4]
+12 -6
View File
@@ -1,23 +1,29 @@
module github.com/zarz/spotiflac_android/go_backend module github.com/zarz/spotiflac_android/go_backend
go 1.24.0 go 1.25.0
toolchain go1.24.5 toolchain go1.25.6
require ( require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0 github.com/go-flac/go-flac v1.0.0
github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4
golang.org/x/net v0.49.0
) )
require ( require (
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect github.com/klauspost/compress v1.17.4 // indirect
golang.org/x/mod v0.31.0 // indirect golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/tools v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
) )
+22 -8
View File
@@ -1,5 +1,7 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
@@ -12,17 +14,29 @@ github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+15 -100
View File
@@ -15,76 +15,29 @@ import (
"time" "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
func getRandomUserAgent() string { func getRandomUserAgent() string {
// Windows 10/11 Chrome format - same as PC version for maximum compatibility // Chrome version 120-145 (modern range)
// Some APIs may block mobile User-Agents, so we use desktop format chromeVersion := rand.Intn(26) + 120
winMajor := rand.Intn(2) + 10 // Windows 10 or 11 chromeBuild := rand.Intn(1500) + 6000
chromePatch := rand.Intn(200) + 100
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
chromePatch := rand.Intn(65) + 60 // Patch 60-125
return fmt.Sprintf( 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", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
winMajor,
chromeVersion, chromeVersion,
chromeBuild, chromeBuild,
chromePatch, chromePatch,
) )
} }
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
// Alternative format matching referensi/backend/spotify_metadata.go exactly
// Kept for potential future use
// 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
// Kept for potential future use
// func getRandomDesktopUserAgent() string {
// if rand.Intn(2) == 0 {
// return getRandomUserAgent() // Windows
// }
// return getRandomMacUserAgent() // Mac
// }
// Default timeout values
const ( const (
DefaultTimeout = 60 * time.Second // Default HTTP timeout DefaultTimeout = 60 * time.Second
DownloadTimeout = 120 * time.Second // Timeout for file downloads DownloadTimeout = 120 * time.Second
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3 // Default retry count DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second // Initial retry delay DefaultRetryDelay = 1 * time.Second
Second = time.Second
) )
// Shared transport with connection pooling to prevent TCP exhaustion
// Optimized for large file downloads (FLAC ~30-50MB)
var sharedTransport = &http.Transport{ var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
@@ -96,27 +49,23 @@ var sharedTransport = &http.Transport{
IdleConnTimeout: 90 * time.Second, IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second, ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse DisableKeepAlives: false,
ForceAttemptHTTP2: true, ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024, // 64KB write buffer WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024, // 64KB read buffer ReadBufferSize: 64 * 1024,
DisableCompression: true, // FLAC is already compressed DisableCompression: true,
} }
// Shared HTTP client for general requests (reuses connections)
var sharedClient = &http.Client{ var sharedClient = &http.Client{
Transport: sharedTransport, Transport: sharedTransport,
Timeout: DefaultTimeout, Timeout: DefaultTimeout,
} }
// Shared HTTP client for downloads (longer timeout, reuses connections)
var downloadClient = &http.Client{ var downloadClient = &http.Client{
Transport: sharedTransport, Transport: sharedTransport,
Timeout: DownloadTimeout, Timeout: DownloadTimeout,
} }
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
// Uses shared transport for connection reuse
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{ return &http.Client{
Transport: sharedTransport, Transport: sharedTransport,
@@ -124,29 +73,23 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
} }
} }
// GetSharedClient returns the shared HTTP client for general requests
func GetSharedClient() *http.Client { func GetSharedClient() *http.Client {
return sharedClient return sharedClient
} }
// GetDownloadClient returns the shared HTTP client for downloads
func GetDownloadClient() *http.Client { func GetDownloadClient() *http.Client {
return downloadClient return downloadClient
} }
// CloseIdleConnections closes idle connections in the shared transport
// Call this periodically during large batch downloads to prevent connection buildup
func CloseIdleConnections() { func CloseIdleConnections() {
sharedTransport.CloseIdleConnections() sharedTransport.CloseIdleConnections()
} }
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
// Also checks for ISP blocking on errors // Also checks for ISP blocking on errors
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
// Check for ISP blocking
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
} }
return resp, err return resp, err
@@ -160,7 +103,6 @@ type RetryConfig struct {
BackoffFactor float64 BackoffFactor float64
} }
// DefaultRetryConfig returns default retry configuration
func DefaultRetryConfig() RetryConfig { func DefaultRetryConfig() RetryConfig {
return RetryConfig{ return RetryConfig{
MaxRetries: DefaultMaxRetries, MaxRetries: DefaultMaxRetries,
@@ -170,9 +112,6 @@ func DefaultRetryConfig() RetryConfig {
} }
} }
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
// Handles 429 (Too Many Requests) responses with Retry-After header
// Also detects and logs ISP blocking
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) { func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
var lastErr error var lastErr error
delay := config.InitialDelay delay := config.InitialDelay
@@ -202,12 +141,10 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue continue
} }
// Success
if resp.StatusCode >= 200 && resp.StatusCode < 300 { if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return resp, nil return resp, nil
} }
// Handle rate limiting (429)
if resp.StatusCode == 429 { if resp.StatusCode == 429 {
resp.Body.Close() resp.Body.Close()
retryAfter := getRetryAfterDuration(resp) retryAfter := getRetryAfterDuration(resp)
@@ -247,7 +184,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
} }
} }
// Server errors (5xx) - retry
if resp.StatusCode >= 500 { if resp.StatusCode >= 500 {
resp.Body.Close() resp.Body.Close()
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode) lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
@@ -259,20 +195,17 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue continue
} }
// Client errors (4xx except 429) - don't retry
return resp, nil return resp, nil
} }
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr) 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 { func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor) nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
return min(nextDelay, config.MaxDelay) return min(nextDelay, config.MaxDelay)
} }
// getRetryAfterDuration parses Retry-After header and returns duration
// Returns 60 seconds as default if header is missing or invalid // Returns 60 seconds as default if header is missing or invalid
func getRetryAfterDuration(resp *http.Response) time.Duration { func getRetryAfterDuration(resp *http.Response) time.Duration {
retryAfter := resp.Header.Get("Retry-After") retryAfter := resp.Header.Get("Retry-After")
@@ -280,12 +213,10 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
return 60 * time.Second // Default wait time return 60 * time.Second // Default wait time
} }
// Try parsing as seconds
if seconds, err := strconv.Atoi(retryAfter); err == nil { if seconds, err := strconv.Atoi(retryAfter); err == nil {
return time.Duration(seconds) * time.Second return time.Duration(seconds) * time.Second
} }
// Try parsing as HTTP date
if t, err := http.ParseTime(retryAfter); err == nil { if t, err := http.ParseTime(retryAfter); err == nil {
duration := time.Until(t) duration := time.Until(t)
if duration > 0 { if duration > 0 {
@@ -296,8 +227,6 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
return 60 * time.Second // Default return 60 * time.Second // Default
} }
// ReadResponseBody reads and returns the response body
// Returns error if body is empty
func ReadResponseBody(resp *http.Response) ([]byte, error) { func ReadResponseBody(resp *http.Response) ([]byte, error) {
if resp == nil { if resp == nil {
return nil, fmt.Errorf("response is nil") return nil, fmt.Errorf("response is nil")
@@ -315,7 +244,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
return body, nil return body, nil
} }
// ValidateResponse checks if response is valid (non-nil, status 2xx)
func ValidateResponse(resp *http.Response) error { func ValidateResponse(resp *http.Response) error {
if resp == nil { if resp == nil {
return fmt.Errorf("response is nil") return fmt.Errorf("response is nil")
@@ -328,14 +256,12 @@ func ValidateResponse(resp *http.Response) error {
return nil return nil
} }
// BuildErrorMessage creates a detailed error message for API failures
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string { func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
msg := fmt.Sprintf("API %s failed", apiURL) msg := fmt.Sprintf("API %s failed", apiURL)
if statusCode > 0 { if statusCode > 0 {
msg += fmt.Sprintf(" (HTTP %d)", statusCode) msg += fmt.Sprintf(" (HTTP %d)", statusCode)
} }
if responsePreview != "" { if responsePreview != "" {
// Truncate preview if too long
if len(responsePreview) > 100 { if len(responsePreview) > 100 {
responsePreview = responsePreview[:100] + "..." responsePreview = responsePreview[:100] + "..."
} }
@@ -344,7 +270,6 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
return msg return msg
} }
// ISPBlockingError represents an error caused by ISP blocking
type ISPBlockingError struct { type ISPBlockingError struct {
Domain string Domain string
Reason string Reason string
@@ -355,18 +280,14 @@ func (e *ISPBlockingError) Error() string {
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason) return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
} }
// IsISPBlocking checks if an error is likely caused by ISP blocking
// Returns the ISPBlockingError if detected, nil otherwise
func IsISPBlocking(err error, requestURL string) *ISPBlockingError { func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil { if err == nil {
return nil return nil
} }
// Extract domain from URL
domain := extractDomain(requestURL) domain := extractDomain(requestURL)
errStr := strings.ToLower(err.Error()) errStr := strings.ToLower(err.Error())
// Check for DNS resolution failure (common ISP blocking method)
var dnsErr *net.DNSError var dnsErr *net.DNSError
if errors.As(err, &dnsErr) { if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound || dnsErr.IsTemporary { if dnsErr.IsNotFound || dnsErr.IsTemporary {
@@ -378,11 +299,9 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
} }
} }
// Check for connection refused (ISP firewall blocking)
var opErr *net.OpError var opErr *net.OpError
if errors.As(err, &opErr) { if errors.As(err, &opErr) {
if opErr.Op == "dial" { if opErr.Op == "dial" {
// Check for specific syscall errors
var syscallErr syscall.Errno var syscallErr syscall.Errno
if errors.As(opErr.Err, &syscallErr) { if errors.As(opErr.Err, &syscallErr) {
switch syscallErr { switch syscallErr {
@@ -421,7 +340,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
} }
} }
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
var tlsErr *tls.RecordHeaderError var tlsErr *tls.RecordHeaderError
if errors.As(err, &tlsErr) { if errors.As(err, &tlsErr) {
return &ISPBlockingError{ return &ISPBlockingError{
@@ -460,7 +378,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
return nil return nil
} }
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
// Returns true if ISP blocking was detected // Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL) ispErr := IsISPBlocking(err, requestURL)
@@ -483,7 +400,6 @@ func extractDomain(rawURL string) string {
parsed, err := url.Parse(rawURL) parsed, err := url.Parse(rawURL)
if err != nil { if err != nil {
// Try to extract domain manually
rawURL = strings.TrimPrefix(rawURL, "https://") rawURL = strings.TrimPrefix(rawURL, "https://")
rawURL = strings.TrimPrefix(rawURL, "http://") rawURL = strings.TrimPrefix(rawURL, "http://")
if idx := strings.Index(rawURL, "/"); idx > 0 { if idx := strings.Index(rawURL, "/"); idx > 0 {
@@ -498,7 +414,6 @@ func extractDomain(rawURL string) string {
return "unknown" return "unknown"
} }
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
// If ISP blocking is detected, returns a more descriptive error // If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error { func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil { 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
}
+3 -11
View File
@@ -8,7 +8,6 @@ import (
"time" "time"
) )
// LogEntry represents a single log entry
type LogEntry struct { type LogEntry struct {
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`
Level string `json:"level"` Level string `json:"level"`
@@ -16,12 +15,11 @@ type LogEntry struct {
Message string `json:"message"` Message string `json:"message"`
} }
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
type LogBuffer struct { type LogBuffer struct {
entries []LogEntry entries []LogEntry
maxSize int maxSize int
mu sync.RWMutex mu sync.RWMutex
loggingEnabled bool // Whether logging is enabled (controlled by Flutter) loggingEnabled bool
} }
var ( var (
@@ -41,7 +39,6 @@ func GetLogBuffer() *LogBuffer {
return globalLogBuffer return globalLogBuffer
} }
// SetLoggingEnabled enables or disables logging
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) { func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock() lb.mu.Lock()
defer lb.mu.Unlock() defer lb.mu.Unlock()
@@ -55,12 +52,10 @@ func (lb *LogBuffer) IsLoggingEnabled() bool {
return lb.loggingEnabled return lb.loggingEnabled
} }
// Add adds a log entry to the buffer
func (lb *LogBuffer) Add(level, tag, message string) { func (lb *LogBuffer) Add(level, tag, message string) {
lb.mu.Lock() lb.mu.Lock()
defer lb.mu.Unlock() defer lb.mu.Unlock()
// Skip if logging is disabled (except for errors which are always logged)
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" { if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
return return
} }
@@ -73,12 +68,10 @@ func (lb *LogBuffer) Add(level, tag, message string) {
} }
if len(lb.entries) >= lb.maxSize { if len(lb.entries) >= lb.maxSize {
// Remove oldest entry
lb.entries = lb.entries[1:] lb.entries = lb.entries[1:]
} }
lb.entries = append(lb.entries, entry) lb.entries = append(lb.entries, entry)
// Also print to logcat for debugging
fmt.Printf("[%s] %s\n", tag, message) fmt.Printf("[%s] %s\n", tag, message)
} }
@@ -91,7 +84,6 @@ func (lb *LogBuffer) GetAll() string {
return string(jsonBytes) return string(jsonBytes)
} }
// getSince returns log entries since the given index (internal use)
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) { func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
lb.mu.RLock() lb.mu.RLock()
defer lb.mu.RUnlock() defer lb.mu.RUnlock()
@@ -158,11 +150,11 @@ func GoLog(format string, args ...interface{}) {
// Determine level from message content // Determine level from message content
msgLower := strings.ToLower(message) msgLower := strings.ToLower(message)
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") { if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
level = "ERROR" level = "ERROR"
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") { } else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
level = "WARN" level = "WARN"
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") { } else if strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
level = "INFO" level = "INFO"
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") { } else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
level = "DEBUG" level = "DEBUG"
+191 -53
View File
@@ -3,14 +3,93 @@ package gobackend
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
) )
const (
lyricsCacheTTL = 24 * time.Hour
durationToleranceSec = 10.0
)
type lyricsCacheEntry struct {
response *LyricsResponse
expiresAt time.Time
}
type lyricsCache struct {
mu sync.RWMutex
cache map[string]*lyricsCacheEntry
}
var globalLyricsCache = &lyricsCache{
cache: make(map[string]*lyricsCacheEntry),
}
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
roundedDuration := math.Round(durationSec/10) * 10
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
}
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := c.generateKey(artist, track, durationSec)
entry, exists := c.cache[key]
if !exists {
return nil, false
}
if time.Now().After(entry.expiresAt) {
return nil, false
}
return entry.response, true
}
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
c.mu.Lock()
defer c.mu.Unlock()
key := c.generateKey(artist, track, durationSec)
c.cache[key] = &lyricsCacheEntry{
response: response,
expiresAt: time.Now().Add(lyricsCacheTTL),
}
}
func (c *lyricsCache) CleanExpired() int {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
cleaned := 0
for key, entry := range c.cache {
if now.After(entry.expiresAt) {
delete(c.cache, key)
cleaned++
}
}
return cleaned
}
func (c *lyricsCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
type LRCLibResponse struct { type LRCLibResponse struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -44,9 +123,7 @@ type LyricsClient struct {
func NewLyricsClient() *LyricsClient { func NewLyricsClient() *LyricsClient {
return &LyricsClient{ return &LyricsClient{
httpClient: &http.Client{ httpClient: NewHTTPClientWithTimeout(15 * time.Second),
Timeout: 15 * time.Second,
},
} }
} }
@@ -86,7 +163,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
return c.parseLRCLibResponse(&lrcResp), nil return c.parseLRCLibResponse(&lrcResp), nil
} }
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsResponse, error) { func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
baseURL := "https://lrclib.net/api/search" baseURL := "https://lrclib.net/api/search"
params := url.Values{} params := url.Values{}
params.Set("q", query) params.Set("q", query)
@@ -118,6 +195,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
return nil, fmt.Errorf("no lyrics found") return nil, fmt.Errorf("no lyrics found")
} }
bestMatch := c.findBestMatch(results, durationSec)
if bestMatch != nil {
return c.parseLRCLibResponse(bestMatch), nil
}
for _, result := range results { for _, result := range results {
if result.SyncedLyrics != "" { if result.SyncedLyrics != "" {
return c.parseLRCLibResponse(&result), nil return c.parseLRCLibResponse(&result), nil
@@ -127,38 +209,92 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
return c.parseLRCLibResponse(&results[0]), nil return c.parseLRCLibResponse(&results[0]), nil
} }
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) { func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
// Strategy 1: Direct match with artist and track name var bestSynced *LRCLibResponse
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName) var bestPlain *LRCLibResponse
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
for i := range results {
result := &results[i]
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
if durationMatches {
if result.SyncedLyrics != "" && bestSynced == nil {
bestSynced = result
} else if result.PlainLyrics != "" && bestPlain == nil {
bestPlain = result
}
}
}
if bestSynced != nil {
return bestSynced
}
return bestPlain
}
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
diff := math.Abs(lrcDuration - targetDuration)
return diff <= durationToleranceSec
}
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
primaryArtist := normalizeArtistName(artistName)
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached
cachedCopy.Source = cached.Source + " (cached)"
return &cachedCopy, nil
}
var lyrics *LyricsResponse
var err error
isValidResult := func(l *LyricsResponse) bool {
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
}
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB" lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
} }
// Strategy 2: Try with simplified track name if primaryArtist != artistName {
simplifiedTrack := simplifyTrackName(trackName) lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if simplifiedTrack != trackName { if err == nil && isValidResult(lyrics) {
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack) lyrics.Source = "LRCLIB"
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
lyrics.Source = "LRCLIB (simplified)"
return lyrics, nil return lyrics, nil
} }
} }
// Strategy 3: Search with full query simplifiedTrack := simplifyTrackName(trackName)
query := artistName + " " + trackName if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsFromLRCLibSearch(query) lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
query := primaryArtist + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search" lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
} }
// Strategy 4: Search with simplified query
if simplifiedTrack != trackName { if simplifiedTrack != trackName {
query = artistName + " " + simplifiedTrack query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query) lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search (simplified)" lyrics.Source = "LRCLIB Search (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil return lyrics, nil
} }
} }
@@ -248,35 +384,6 @@ func msToLRCTimestamp(ms int64) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
} }
// 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 {
// if lyrics == nil || len(lyrics.Lines) == 0 {
// return ""
// }
//
// var builder strings.Builder
//
// if lyrics.SyncType == "LINE_SYNCED" {
// for _, line := range lyrics.Lines {
// timestamp := msToLRCTimestamp(line.StartTimeMs)
// builder.WriteString(timestamp)
// builder.WriteString(line.Words)
// builder.WriteString("\n")
// }
// } else {
// for _, line := range lyrics.Lines {
// builder.WriteString(line.Words)
// builder.WriteString("\n")
// }
// }
//
// return builder.String()
// }
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
// Includes [ti:], [ar:], [by:] headers
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string { func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
if lyrics == nil || len(lyrics.Lines) == 0 { if lyrics == nil || len(lyrics.Lines) == 0 {
return "" return ""
@@ -284,13 +391,11 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
var builder strings.Builder var builder strings.Builder
// Add metadata headers
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
builder.WriteString("[by:SpotiFLAC-Mobile]\n") builder.WriteString("[by:SpotiFLAC-Mobile]\n")
builder.WriteString("\n") builder.WriteString("\n")
// Add lyrics lines
if lyrics.SyncType == "LINE_SYNCED" { if lyrics.SyncType == "LINE_SYNCED" {
for _, line := range lyrics.Lines { for _, line := range lyrics.Lines {
if line.Words == "" { if line.Words == "" {
@@ -339,3 +444,36 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result) return strings.TrimSpace(result)
} }
func normalizeArtistName(name string) string {
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
result := name
for _, sep := range separators {
if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 {
result = result[:idx]
break
}
}
return strings.TrimSpace(result)
}
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
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
}
+277 -377
View File
@@ -1,7 +1,10 @@
package gobackend package gobackend
import ( import (
"bytes"
"encoding/binary"
"fmt" "fmt"
"io"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -11,7 +14,6 @@ import (
"github.com/go-flac/go-flac" "github.com/go-flac/go-flac"
) )
// Metadata represents track metadata for embedding
type Metadata struct { type Metadata struct {
Title string Title string
Artist string Artist string
@@ -24,16 +26,17 @@ type Metadata struct {
ISRC string ISRC string
Description string Description string
Lyrics 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 { func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err) return fmt.Errorf("failed to parse FLAC file: %w", err)
} }
// Find or create vorbis comment block
var cmtIdx int = -1 var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment var cmt *flacvorbis.MetaDataBlockVorbisComment
@@ -52,7 +55,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
cmt = flacvorbis.New() cmt = flacvorbis.New()
} }
// Set metadata fields
setComment(cmt, "TITLE", metadata.Title) setComment(cmt, "TITLE", metadata.Title)
setComment(cmt, "ARTIST", metadata.Artist) setComment(cmt, "ARTIST", metadata.Artist)
setComment(cmt, "ALBUM", metadata.Album) setComment(cmt, "ALBUM", metadata.Album)
@@ -84,7 +86,18 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics) setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
} }
// Update or add vorbis comment block 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() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock f.Meta[cmtIdx] = &cmtBlock
@@ -92,14 +105,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
f.Meta = append(f.Meta, &cmtBlock) f.Meta = append(f.Meta, &cmtBlock)
} }
// Add cover art if provided
if coverPath != "" { if coverPath != "" {
if fileExists(coverPath) { if fileExists(coverPath) {
coverData, err := os.ReadFile(coverPath) coverData, err := os.ReadFile(coverPath)
if err != nil { if err != nil {
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err) fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
} else { } else {
// Remove existing picture blocks first (like PC version)
for i := len(f.Meta) - 1; i >= 0; i-- { for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture { if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
@@ -125,19 +136,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
} }
} }
// Save file
return f.Save(filePath) 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 { func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err) return fmt.Errorf("failed to parse FLAC file: %w", err)
} }
// Find or create vorbis comment block
var cmtIdx int = -1 var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment var cmt *flacvorbis.MetaDataBlockVorbisComment
@@ -156,7 +163,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
cmt = flacvorbis.New() cmt = flacvorbis.New()
} }
// Set metadata fields
setComment(cmt, "TITLE", metadata.Title) setComment(cmt, "TITLE", metadata.Title)
setComment(cmt, "ARTIST", metadata.Artist) setComment(cmt, "ARTIST", metadata.Artist)
setComment(cmt, "ALBUM", metadata.Album) setComment(cmt, "ALBUM", metadata.Album)
@@ -188,7 +194,18 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics) setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
} }
// Update or add vorbis comment block 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() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock f.Meta[cmtIdx] = &cmtBlock
@@ -196,9 +213,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
f.Meta = append(f.Meta, &cmtBlock) f.Meta = append(f.Meta, &cmtBlock)
} }
// Add cover art if provided
if len(coverData) > 0 { if len(coverData) > 0 {
// Remove existing picture blocks first
for i := len(f.Meta) - 1; i >= 0; i-- { for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture { if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
@@ -220,11 +235,9 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
} }
} }
// Save file
return f.Save(filePath) return f.Save(filePath)
} }
// ReadMetadata reads metadata from a FLAC file
func ReadMetadata(filePath string) (*Metadata, error) { func ReadMetadata(filePath string) (*Metadata, error) {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -257,7 +270,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if trackNum != "" { if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
} }
// Also try lowercase variant (some encoders use lowercase)
if metadata.TrackNumber == 0 { if metadata.TrackNumber == 0 {
trackNum = getComment(cmt, "TRACK") trackNum = getComment(cmt, "TRACK")
if trackNum != "" { if trackNum != "" {
@@ -269,7 +281,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if discNum != "" { if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
} }
// Also try DISC variant
if metadata.DiscNumber == 0 { if metadata.DiscNumber == 0 {
discNum = getComment(cmt, "DISC") discNum = getComment(cmt, "DISC")
if discNum != "" { if discNum != "" {
@@ -277,7 +288,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
} }
} }
// Try DATE variants
if metadata.Date == "" { if metadata.Date == "" {
metadata.Date = getComment(cmt, "YEAR") metadata.Date = getComment(cmt, "YEAR")
} }
@@ -293,7 +303,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" { if value == "" {
return return
} }
// Remove existing (case-insensitive comparison for Vorbis comments)
keyUpper := strings.ToUpper(key) keyUpper := strings.ToUpper(key)
for i := len(cmt.Comments) - 1; i >= 0; i-- { for i := len(cmt.Comments) - 1; i >= 0; i-- {
comment := cmt.Comments[i] comment := cmt.Comments[i]
@@ -305,7 +314,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
} }
} }
} }
// Add new
cmt.Comments = append(cmt.Comments, key+"="+value) cmt.Comments = append(cmt.Comments, key+"="+value)
} }
@@ -313,7 +321,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
keyUpper := strings.ToUpper(key) + "=" keyUpper := strings.ToUpper(key) + "="
for _, comment := range cmt.Comments { for _, comment := range cmt.Comments {
if len(comment) > len(key) { if len(comment) > len(key) {
// Case-insensitive comparison for Vorbis comments
commentUpper := strings.ToUpper(comment[:len(key)+1]) commentUpper := strings.ToUpper(comment[:len(key)+1])
if commentUpper == keyUpper { if commentUpper == keyUpper {
return comment[len(key)+1:] return comment[len(key)+1:]
@@ -323,13 +330,44 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
return "" return ""
} }
// fileExists checks if a file exists
func fileExists(path string) bool { func fileExists(path string) bool {
_, err := os.Stat(path) _, err := os.Stat(path)
return err == nil return err == nil
} }
// EmbedLyrics embeds lyrics into a FLAC file as a separate operation func ExtractCoverArt(filePath string) ([]byte, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, meta := range f.Meta {
if meta.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
if pic.PictureType == flacpicture.PictureTypeFrontCover && len(pic.ImageData) > 0 {
return pic.ImageData, nil
}
}
}
for _, meta := range f.Meta {
if meta.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
if len(pic.ImageData) > 0 {
return pic.ImageData, nil
}
}
}
return nil, fmt.Errorf("no cover art found in file")
}
func EmbedLyrics(filePath string, lyrics string) error { func EmbedLyrics(filePath string, lyrics string) error {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -367,7 +405,51 @@ func EmbedLyrics(filePath string, lyrics string) error {
return f.Save(filePath) return f.Save(filePath)
} }
// ExtractLyrics extracts embedded lyrics from a FLAC file 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)
}
func ExtractLyrics(filePath string) (string, error) { func ExtractLyrics(filePath string) (string, error) {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -381,13 +463,11 @@ func ExtractLyrics(filePath string) (string, error) {
continue continue
} }
// Try LYRICS tag first
lyrics, err := cmt.Get("LYRICS") lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" { if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil return lyrics[0], nil
} }
// Fallback to UNSYNCEDLYRICS
lyrics, err = cmt.Get("UNSYNCEDLYRICS") lyrics, err = cmt.Get("UNSYNCEDLYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" { if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil return lyrics[0], nil
@@ -398,16 +478,12 @@ func ExtractLyrics(filePath string) (string, error) {
return "", fmt.Errorf("no lyrics found in file") return "", fmt.Errorf("no lyrics found in file")
} }
// AudioQuality represents audio quality info from a FLAC file
type AudioQuality struct { type AudioQuality struct {
BitDepth int `json:"bit_depth"` BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"` SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"` 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) { func GetAudioQuality(filePath string) (AudioQuality, error) {
file, err := os.Open(filePath) file, err := os.Open(filePath)
if err != nil { if err != nil {
@@ -415,16 +491,12 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
} }
defer file.Close() defer file.Close()
// Read first 4 bytes to detect file type
marker := make([]byte, 4) marker := make([]byte, 4)
if _, err := file.Read(marker); err != nil { if _, err := file.Read(marker); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err) return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
} }
// Check if it's a FLAC file
if string(marker) == "fLaC" { if string(marker) == "fLaC" {
// Continue reading FLAC metadata
// Read metadata block header (4 bytes)
header := make([]byte, 4) header := make([]byte, 4)
if _, err := file.Read(header); err != nil { if _, err := file.Read(header); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err) return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
@@ -435,19 +507,15 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO") return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
} }
// Read STREAMINFO block (34 bytes minimum)
streamInfo := make([]byte, 34) streamInfo := make([]byte, 34)
if _, err := file.Read(streamInfo); err != nil { if _, err := file.Read(streamInfo); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err) return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
} }
// Parse sample rate (20 bits starting at byte 10)
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4) sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
// Parse bits per sample (5 bits)
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1 bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
totalSamples := int64(streamInfo[13]&0x0F)<<32 | totalSamples := int64(streamInfo[13]&0x0F)<<32 |
int64(streamInfo[14])<<24 | int64(streamInfo[14])<<24 |
int64(streamInfo[15])<<16 | int64(streamInfo[15])<<16 |
@@ -461,362 +529,194 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
}, nil }, nil
} }
// Check if it's an M4A/MP4 file (starts with size + "ftyp") file.Seek(0, 0)
// First 4 bytes are size, next 4 should be "ftyp"
file.Seek(0, 0) // Reset to beginning
header8 := make([]byte, 8) header8 := make([]byte, 8)
if _, err := file.Read(header8); err != nil { if _, err := file.Read(header8); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err) return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
} }
if string(header8[4:8]) == "ftyp" { if string(header8[4:8]) == "ftyp" {
// It's an M4A/MP4 file, use M4A quality reader file.Close()
file.Close() // Close before calling GetM4AQuality which opens the file again
return GetM4AQuality(filePath) return GetM4AQuality(filePath)
} }
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)") return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
} }
// ========================================
// M4A (MP4/AAC) Metadata Embedding
// ========================================
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
// This is a simplified implementation that writes metadata to the file
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
// Read the entire file
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read M4A file: %w", err)
}
// Find moov atom position
moovPos := findAtom(data, "moov", 0)
if moovPos < 0 {
return fmt.Errorf("moov atom not found in M4A file")
}
// Find udta atom inside moov, or create one
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)
// Build new metadata atoms
metaAtom := buildMetaAtom(metadata, coverData)
var newData []byte
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
// udta exists, find meta inside it or replace
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)
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
// Replace existing meta atom
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 {
// Add meta atom to udta
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...)
newData = append(newData, data[:udtaPos]...)
newData = append(newData, newUdta...)
newData = append(newData, data[udtaPos+udtaSize:]...)
}
} else {
// Create new udta with meta
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...)
// Insert udta at end of moov
insertPos := moovPos + moovSize
newData = append(newData, data[:insertPos]...)
newData = append(newData, newUdta...)
newData = append(newData, data[insertPos:]...)
}
// Update moov size
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)
// Write back to file
if err := os.WriteFile(filePath, newData, 0644); err != nil {
return fmt.Errorf("failed to write M4A file: %w", err)
}
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 {
// Build ilst content
var ilst []byte
// ©nam - Title
if metadata.Title != "" {
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
}
// ©ART - Artist
if metadata.Artist != "" {
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
}
// ©alb - Album
if metadata.Album != "" {
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
}
// aART - Album Artist
if metadata.AlbumArtist != "" {
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
}
// ©day - Year/Date
if metadata.Date != "" {
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
}
// trkn - Track Number
if metadata.TrackNumber > 0 {
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
}
// disk - Disc Number
if metadata.DiscNumber > 0 {
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
}
// ©lyr - Lyrics
if metadata.Lyrics != "" {
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
}
// covr - Cover Art
if len(coverData) > 0 {
ilst = append(ilst, buildCoverAtom(coverData)...)
}
// Build ilst atom
ilstSize := 8 + len(ilst)
ilstAtom := make([]byte, 4)
ilstAtom[0] = byte(ilstSize >> 24)
ilstAtom[1] = byte(ilstSize >> 16)
ilstAtom[2] = byte(ilstSize >> 8)
ilstAtom[3] = byte(ilstSize)
ilstAtom = append(ilstAtom, []byte("ilst")...)
ilstAtom = append(ilstAtom, ilst...)
// Build hdlr atom (required for meta)
hdlr := []byte{
0, 0, 0, 33, // size = 33
'h', 'd', 'l', 'r',
0, 0, 0, 0, // version + flags
0, 0, 0, 0, // predefined
'm', 'd', 'i', 'r', // handler type
'a', 'p', 'p', 'l', // manufacturer
0, 0, 0, 0, // component flags
0, 0, 0, 0, // component flags mask
0, // null terminator
}
// Build meta atom
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
metaContent = append(metaContent, ilstAtom...)
metaSize := 8 + len(metaContent)
metaAtom := make([]byte, 4)
metaAtom[0] = byte(metaSize >> 24)
metaAtom[1] = byte(metaSize >> 16)
metaAtom[2] = byte(metaSize >> 8)
metaAtom[3] = byte(metaSize)
metaAtom = append(metaAtom, []byte("meta")...)
metaAtom = append(metaAtom, metaContent...)
return metaAtom
}
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
func buildTextAtom(name, value string) []byte {
valueBytes := []byte(value)
// data atom
dataSize := 16 + len(valueBytes)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
dataAtom[1] = byte(dataSize >> 16)
dataAtom[2] = byte(dataSize >> 8)
dataAtom[3] = byte(dataSize)
dataAtom = append(dataAtom, []byte("data")...)
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, valueBytes...)
// container atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte(name)...)
atom = append(atom, dataAtom...)
return atom
}
// buildTrackNumberAtom builds trkn atom
func buildTrackNumberAtom(track, total int) []byte {
// data atom with track number
dataAtom := []byte{
0, 0, 0, 24, // size
'd', 'a', 't', 'a',
0, 0, 0, 0, // type = implicit
0, 0, 0, 0, // locale
0, 0, // padding
byte(track >> 8), byte(track), // track number
byte(total >> 8), byte(total), // total tracks
0, 0, // padding
}
// trkn atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte("trkn")...)
atom = append(atom, dataAtom...)
return atom
}
// buildDiscNumberAtom builds disk atom
func buildDiscNumberAtom(disc, total int) []byte {
// data atom with disc number
dataAtom := []byte{
0, 0, 0, 22, // size
'd', 'a', 't', 'a',
0, 0, 0, 0, // type = implicit
0, 0, 0, 0, // locale
0, 0, // padding
byte(disc >> 8), byte(disc), // disc number
byte(total >> 8), byte(total), // total discs
}
// disk atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte("disk")...)
atom = append(atom, dataAtom...)
return atom
}
// buildCoverAtom builds covr atom with image data
func buildCoverAtom(coverData []byte) []byte {
// Detect image type (JPEG = 13, PNG = 14)
imageType := byte(13) // default JPEG
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
imageType = 14 // PNG
}
// data atom
dataSize := 16 + len(coverData)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
dataAtom[1] = byte(dataSize >> 16)
dataAtom[2] = byte(dataSize >> 8)
dataAtom[3] = byte(dataSize)
dataAtom = append(dataAtom, []byte("data")...)
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, coverData...)
// covr atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte("covr")...)
atom = append(atom, dataAtom...)
return atom
}
// GetM4AQuality reads audio quality from M4A file
func GetM4AQuality(filePath string) (AudioQuality, error) { func GetM4AQuality(filePath string) (AudioQuality, error) {
data, err := os.ReadFile(filePath) f, err := os.Open(filePath)
if err != nil { 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()
// Find moov -> trak -> mdia -> minf -> stbl -> stsd info, err := f.Stat()
moovPos := findAtom(data, "moov", 0) if err != nil {
if moovPos < 0 { 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") return AudioQuality{}, fmt.Errorf("moov atom not found")
} }
// Search for mp4a or alac atom which contains audio info moovStart := moovHeader.offset
// This is a simplified search - real implementation would traverse the atom tree moovEnd := moovHeader.offset + moovHeader.size
for i := moovPos; i < len(data)-20; i++ {
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" { sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize)
// Sample rate is at offset 22-23 from atom start (16-bit big-endian) if err != nil {
if i+24 < len(data) { return AudioQuality{}, err
sampleRate := int(data[i+22])<<8 | int(data[i+23])
// For AAC, bit depth is typically 16
bitDepth := 16
if string(data[i:i+4]) == "alac" {
// ALAC can have higher bit depth, check esds or alac specific data
bitDepth = 24 // Assume 24-bit for ALAC
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
}
}
} }
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 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"
)
+62 -76
View File
@@ -6,11 +6,6 @@ import (
"time" "time"
) )
// ========================================
// ISRC to Track ID Cache
// ========================================
// TrackIDCacheEntry holds cached track ID with metadata
type TrackIDCacheEntry struct { type TrackIDCacheEntry struct {
TidalTrackID int64 TidalTrackID int64
QobuzTrackID int64 QobuzTrackID int64
@@ -18,11 +13,12 @@ type TrackIDCacheEntry struct {
ExpiresAt time.Time ExpiresAt time.Time
} }
// TrackIDCache caches ISRC to track ID mappings
type TrackIDCache struct { type TrackIDCache struct {
cache map[string]*TrackIDCacheEntry cache map[string]*TrackIDCacheEntry
mu sync.RWMutex mu sync.RWMutex
ttl time.Duration ttl time.Duration
lastCleanup time.Time
cleanupInterval time.Duration
} }
var ( var (
@@ -30,30 +26,48 @@ var (
trackIDCacheOnce sync.Once trackIDCacheOnce sync.Once
) )
// GetTrackIDCache returns the global track ID cache
func GetTrackIDCache() *TrackIDCache { func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() { trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{ globalTrackIDCache = &TrackIDCache{
cache: make(map[string]*TrackIDCacheEntry), cache: make(map[string]*TrackIDCacheEntry),
ttl: 30 * time.Minute, // Cache for 30 minutes ttl: 30 * time.Minute,
cleanupInterval: 5 * time.Minute,
} }
}) })
return globalTrackIDCache return globalTrackIDCache
} }
// Get retrieves a cached entry by ISRC
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry { func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock()
entry, exists := c.cache[isrc] entry, exists := c.cache[isrc]
if !exists || time.Now().After(entry.ExpiresAt) { if !exists {
c.mu.RUnlock()
return nil return nil
} }
return entry expired := time.Now().After(entry.ExpiresAt)
c.mu.RUnlock()
if !expired {
return entry
}
c.mu.Lock()
entry, exists = c.cache[isrc]
if exists && time.Now().After(entry.ExpiresAt) {
delete(c.cache, isrc)
}
c.mu.Unlock()
return nil
}
func (c *TrackIDCache) pruneExpiredLocked(now time.Time) {
for key, entry := range c.cache {
if now.After(entry.ExpiresAt) {
delete(c.cache, key)
}
}
} }
// SetTidal caches Tidal track ID for an ISRC
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) { func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -64,10 +78,15 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.cache[isrc] = entry c.cache[isrc] = entry
} }
entry.TidalTrackID = trackID entry.TidalTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl) now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
c.pruneExpiredLocked(now)
c.lastCleanup = now
}
} }
// SetQobuz caches Qobuz track ID for an ISRC
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -78,10 +97,15 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.cache[isrc] = entry c.cache[isrc] = entry
} }
entry.QobuzTrackID = trackID entry.QobuzTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl) now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
c.pruneExpiredLocked(now)
c.lastCleanup = now
}
} }
// SetAmazon caches Amazon track ID for an ISRC
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) { func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -92,28 +116,27 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
c.cache[isrc] = entry c.cache[isrc] = entry
} }
entry.AmazonTrackID = trackID entry.AmazonTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl) now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
c.pruneExpiredLocked(now)
c.lastCleanup = now
}
} }
// Clear removes all cached entries
func (c *TrackIDCache) Clear() { func (c *TrackIDCache) Clear() {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.cache = make(map[string]*TrackIDCacheEntry) c.cache = make(map[string]*TrackIDCacheEntry)
} }
// Size returns the number of cached entries
func (c *TrackIDCache) Size() int { func (c *TrackIDCache) Size() int {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return len(c.cache) return len(c.cache)
} }
// ========================================
// Parallel Download Helper
// ========================================
// ParallelDownloadResult holds results from parallel operations
type ParallelDownloadResult struct { type ParallelDownloadResult struct {
CoverData []byte CoverData []byte
LyricsData *LyricsResponse LyricsData *LyricsResponse
@@ -122,8 +145,6 @@ type ParallelDownloadResult struct {
LyricsErr error LyricsErr error
} }
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
// This runs while the main audio download is happening
func FetchCoverAndLyricsParallel( func FetchCoverAndLyricsParallel(
coverURL string, coverURL string,
maxQualityCover bool, maxQualityCover bool,
@@ -131,46 +152,38 @@ func FetchCoverAndLyricsParallel(
trackName string, trackName string,
artistName string, artistName string,
embedLyrics bool, embedLyrics bool,
durationMs int64,
) *ParallelDownloadResult { ) *ParallelDownloadResult {
result := &ParallelDownloadResult{} result := &ParallelDownloadResult{}
var wg sync.WaitGroup var wg sync.WaitGroup
// Download cover in parallel
if coverURL != "" { if coverURL != "" {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
fmt.Println("[Parallel] Starting cover download...")
data, err := downloadCoverToMemory(coverURL, maxQualityCover) data, err := downloadCoverToMemory(coverURL, maxQualityCover)
if err != nil { if err != nil {
result.CoverErr = err result.CoverErr = err
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
} else { } else {
result.CoverData = data result.CoverData = data
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
} }
}() }()
} }
// Fetch lyrics in parallel
if embedLyrics { if embedLyrics {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
fmt.Println("[Parallel] Starting lyrics fetch...")
client := NewLyricsClient() client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName) durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil { if err != nil {
result.LyricsErr = err result.LyricsErr = err
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 { } else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = lyrics result.LyricsData = lyrics
// Use LRC with metadata headers (like PC version)
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName) result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else { } else {
result.LyricsErr = fmt.Errorf("no lyrics found") result.LyricsErr = fmt.Errorf("no lyrics found")
fmt.Println("[Parallel] No lyrics found")
} }
}() }()
} }
@@ -179,35 +192,25 @@ func FetchCoverAndLyricsParallel(
return result return result
} }
// ========================================
// Pre-warm Cache for Album/Playlist
// ========================================
// PreWarmCacheRequest represents a track to pre-warm cache for
type PreWarmCacheRequest struct { type PreWarmCacheRequest struct {
ISRC string ISRC string
TrackName string TrackName string
ArtistName string ArtistName string
SpotifyID string // Needed for Amazon (SongLink lookup) SpotifyID string
Service string // "tidal", "qobuz", "amazon" Service string
} }
// 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) { func PreWarmTrackCache(requests []PreWarmCacheRequest) {
if len(requests) == 0 { if len(requests) == 0 {
return return
} }
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
cache := GetTrackIDCache() cache := GetTrackIDCache()
// Limit concurrent pre-warm requests semaphore := make(chan struct{}, 3)
semaphore := make(chan struct{}, 3) // Max 3 concurrent
var wg sync.WaitGroup var wg sync.WaitGroup
for _, req := range requests { for _, req := range requests {
// Skip if already cached
if cached := cache.Get(req.ISRC); cached != nil { if cached := cache.Get(req.ISRC); cached != nil {
continue continue
} }
@@ -215,8 +218,8 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
wg.Add(1) wg.Add(1)
go func(r PreWarmCacheRequest) { go func(r PreWarmCacheRequest) {
defer wg.Done() defer wg.Done()
semaphore <- struct{}{} // Acquire semaphore <- struct{}{}
defer func() { <-semaphore }() // Release defer func() { <-semaphore }()
switch r.Service { switch r.Service {
case "tidal": case "tidal":
@@ -230,7 +233,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
} }
wg.Wait() wg.Wait()
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
} }
func preWarmTidalCache(isrc, _, _ string) { func preWarmTidalCache(isrc, _, _ string) {
@@ -238,7 +240,6 @@ func preWarmTidalCache(isrc, _, _ string) {
track, err := downloader.SearchTrackByISRC(isrc) track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil { if err == nil && track != nil {
GetTrackIDCache().SetTidal(isrc, track.ID) GetTrackIDCache().SetTidal(isrc, track.ID)
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
} }
} }
@@ -247,43 +248,28 @@ func preWarmQobuzCache(isrc string) {
track, err := downloader.SearchTrackByISRC(isrc) track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil { if err == nil && track != nil {
GetTrackIDCache().SetQobuz(isrc, track.ID) GetTrackIDCache().SetQobuz(isrc, track.ID)
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
} }
} }
func preWarmAmazonCache(isrc, spotifyID string) { func preWarmAmazonCache(isrc, spotifyID string) {
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
client := NewSongLinkClient() client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc) availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon { if err == nil && availability != nil && availability.Amazon {
// Store Amazon URL in cache (using ISRC as key)
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL) GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
} }
} }
// ========================================
// 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 { func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest var requests []PreWarmCacheRequest
// Parse JSON (simplified - in production use proper JSON parsing)
// For now, this is called from exports.go with proper parsing
go PreWarmTrackCache(requests) // Run in background go PreWarmTrackCache(requests)
return nil return nil
} }
// ClearTrackCache clears the track ID cache
func ClearTrackCache() { func ClearTrackCache() {
GetTrackIDCache().Clear() GetTrackIDCache().Clear()
fmt.Println("[Cache] Track ID cache cleared")
} }
// GetCacheSize returns the current cache size
func GetCacheSize() int { func GetCacheSize() int {
return GetTrackIDCache().Size() return GetTrackIDCache().Size()
} }
+8 -39
View File
@@ -6,8 +6,6 @@ import (
"time" "time"
) )
// DownloadProgress represents current download progress
// Now unified - returns data from multi-progress system
type DownloadProgress struct { type DownloadProgress struct {
CurrentFile string `json:"current_file"` CurrentFile string `json:"current_file"`
Progress float64 `json:"progress"` Progress float64 `json:"progress"`
@@ -15,21 +13,19 @@ type DownloadProgress struct {
BytesTotal int64 `json:"bytes_total"` BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"` BytesReceived int64 `json:"bytes_received"`
IsDownloading bool `json:"is_downloading"` 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 { type ItemProgress struct {
ItemID string `json:"item_id"` ItemID string `json:"item_id"`
BytesTotal int64 `json:"bytes_total"` BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"` BytesReceived int64 `json:"bytes_received"`
Progress float64 `json:"progress"` // 0.0 to 1.0 Progress float64 `json:"progress"`
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s SpeedMBps float64 `json:"speed_mbps"`
IsDownloading bool `json:"is_downloading"` 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 { type MultiProgress struct {
Items map[string]*ItemProgress `json:"items"` Items map[string]*ItemProgress `json:"items"`
} }
@@ -38,22 +34,18 @@ var (
downloadDir string downloadDir string
downloadDirMu sync.RWMutex downloadDirMu sync.RWMutex
// Multi-download progress tracking (unified system)
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)} multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
multiMu sync.RWMutex multiMu sync.RWMutex
) )
// getProgress returns current download progress from multi-progress system
// Returns first active item's progress for backward compatibility
func getProgress() DownloadProgress { func getProgress() DownloadProgress {
multiMu.RLock() multiMu.RLock()
defer multiMu.RUnlock() defer multiMu.RUnlock()
// Find first active item
for _, item := range multiProgress.Items { for _, item := range multiProgress.Items {
return DownloadProgress{ return DownloadProgress{
CurrentFile: item.ItemID, CurrentFile: item.ItemID,
Progress: item.Progress * 100, // Convert to percentage Progress: item.Progress * 100,
BytesTotal: item.BytesTotal, BytesTotal: item.BytesTotal,
BytesReceived: item.BytesReceived, BytesReceived: item.BytesReceived,
IsDownloading: item.IsDownloading, IsDownloading: item.IsDownloading,
@@ -64,7 +56,6 @@ func getProgress() DownloadProgress {
return DownloadProgress{} return DownloadProgress{}
} }
// GetMultiProgress returns progress for all active downloads as JSON
func GetMultiProgress() string { func GetMultiProgress() string {
multiMu.RLock() multiMu.RLock()
defer multiMu.RUnlock() defer multiMu.RUnlock()
@@ -76,7 +67,6 @@ func GetMultiProgress() string {
return string(jsonBytes) return string(jsonBytes)
} }
// GetItemProgress returns progress for a specific item as JSON
func GetItemProgress(itemID string) string { func GetItemProgress(itemID string) string {
multiMu.RLock() multiMu.RLock()
defer multiMu.RUnlock() defer multiMu.RUnlock()
@@ -88,7 +78,6 @@ func GetItemProgress(itemID string) string {
return "{}" return "{}"
} }
// StartItemProgress initializes progress tracking for an item
func StartItemProgress(itemID string) { func StartItemProgress(itemID string) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -103,7 +92,6 @@ func StartItemProgress(itemID string) {
} }
} }
// SetItemBytesTotal sets total bytes for an item
func SetItemBytesTotal(itemID string, total int64) { func SetItemBytesTotal(itemID string, total int64) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -113,7 +101,6 @@ func SetItemBytesTotal(itemID string, total int64) {
} }
} }
// SetItemBytesReceived sets bytes received for an item
func SetItemBytesReceived(itemID string, received int64) { func SetItemBytesReceived(itemID string, received int64) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -126,7 +113,6 @@ func SetItemBytesReceived(itemID string, received int64) {
} }
} }
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) { func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -140,7 +126,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
} }
} }
// CompleteItemProgress marks an item as complete
func CompleteItemProgress(itemID string) { func CompleteItemProgress(itemID string) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -152,7 +137,6 @@ func CompleteItemProgress(itemID string) {
} }
} }
// SetItemProgress sets progress for an item directly
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) { func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -168,7 +152,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
} }
} }
// SetItemFinalizing marks an item as finalizing (embedding metadata)
func SetItemFinalizing(itemID string) { func SetItemFinalizing(itemID string) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -179,7 +162,6 @@ func SetItemFinalizing(itemID string) {
} }
} }
// RemoveItemProgress removes progress tracking for an item
func RemoveItemProgress(itemID string) { func RemoveItemProgress(itemID string) {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -187,7 +169,6 @@ func RemoveItemProgress(itemID string) {
delete(multiProgress.Items, itemID) delete(multiProgress.Items, itemID)
} }
// ClearAllItemProgress clears all item progress
func ClearAllItemProgress() { func ClearAllItemProgress() {
multiMu.Lock() multiMu.Lock()
defer multiMu.Unlock() defer multiMu.Unlock()
@@ -195,7 +176,6 @@ func ClearAllItemProgress() {
multiProgress.Items = make(map[string]*ItemProgress) multiProgress.Items = make(map[string]*ItemProgress)
} }
// setDownloadDir sets the default download directory
func setDownloadDir(path string) error { func setDownloadDir(path string) error {
downloadDirMu.Lock() downloadDirMu.Lock()
defer downloadDirMu.Unlock() defer downloadDirMu.Unlock()
@@ -203,15 +183,6 @@ func setDownloadDir(path string) error {
return nil 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 { type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) } writer interface{ Write([]byte) (int, error) }
itemID string itemID string
@@ -224,7 +195,6 @@ type ItemProgressWriter struct {
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
// NewItemProgressWriter creates a new progress writer for a specific item
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter { func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
now := time.Now() now := time.Now()
return &ItemProgressWriter{ return &ItemProgressWriter{
@@ -238,18 +208,17 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
} }
} }
// Write implements io.Writer with threshold-based progress updates and speed tracking
func (pw *ItemProgressWriter) Write(p []byte) (int, error) { func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
return 0, ErrDownloadCancelled
}
n, err := pw.writer.Write(p) n, err := pw.writer.Write(p)
if err != nil { if err != nil {
return n, err return n, err
} }
pw.current += int64(n) pw.current += int64(n)
// Update progress when we've received at least 64KB since last update
// Also update on first write to show download has started
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold { if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
// Calculate speed (MB/s) based on bytes received since last update
now := time.Now() now := time.Now()
elapsed := now.Sub(pw.lastTime).Seconds() elapsed := now.Sub(pw.lastTime).Seconds()
var speedMBps float64 var speedMBps float64
+226 -181
View File
@@ -2,8 +2,10 @@ package gobackend
import ( import (
"bufio" "bufio"
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -15,7 +17,6 @@ import (
"time" "time"
) )
// QobuzDownloader handles Qobuz downloads
type QobuzDownloader struct { type QobuzDownloader struct {
client *http.Client client *http.Client
appID string appID string
@@ -23,12 +24,10 @@ type QobuzDownloader struct {
} }
var ( var (
// Global Qobuz downloader instance for connection reuse
globalQobuzDownloader *QobuzDownloader globalQobuzDownloader *QobuzDownloader
qobuzDownloaderOnce sync.Once qobuzDownloaderOnce sync.Once
) )
// QobuzTrack represents a Qobuz track
type QobuzTrack struct { type QobuzTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -49,37 +48,29 @@ type QobuzTrack struct {
} `json:"performer"` } `json:"performer"`
} }
// qobuzArtistsMatch checks if the artist names are similar enough
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound { if normExpected == normFound {
return true return true
} }
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true return true
} }
// Split expected artists by common separators (comma, feat, ft., &, and)
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
expectedArtists := qobuzSplitArtists(normExpected) expectedArtists := qobuzSplitArtists(normExpected)
foundArtists := qobuzSplitArtists(normFound) foundArtists := qobuzSplitArtists(normFound)
// Check if ANY expected artist matches ANY found artist
for _, exp := range expectedArtists { for _, exp := range expectedArtists {
for _, fnd := range foundArtists { for _, fnd := range foundArtists {
if exp == fnd { if exp == fnd {
return true return true
} }
// Also check contains for partial matches
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) { if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true return true
} }
// Check same words different order
if qobuzSameWordsUnordered(exp, fnd) { if qobuzSameWordsUnordered(exp, fnd) {
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd) GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true return true
@@ -87,8 +78,6 @@ func qobuzArtistsMatch(expectedArtist, foundArtist 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
expectedLatin := qobuzIsLatinScript(expectedArtist) expectedLatin := qobuzIsLatinScript(expectedArtist)
foundLatin := qobuzIsLatinScript(foundArtist) foundLatin := qobuzIsLatinScript(foundArtist)
if expectedLatin != foundLatin { if expectedLatin != foundLatin {
@@ -99,9 +88,7 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return false return false
} }
// qobuzSplitArtists splits artist string by common separators
func qobuzSplitArtists(artists string) []string { func qobuzSplitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|") normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|") normalized = strings.ReplaceAll(normalized, " feat ", "|")
@@ -123,24 +110,19 @@ func qobuzSplitArtists(artists string) []string {
return result return result
} }
// qobuzSameWordsUnordered checks if two strings have the same words regardless of order
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
func qobuzSameWordsUnordered(a, b string) bool { func qobuzSameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a) wordsA := strings.Fields(a)
wordsB := strings.Fields(b) wordsB := strings.Fields(b)
// Must have same number of words
if len(wordsA) != len(wordsB) || len(wordsA) == 0 { if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
return false return false
} }
// Sort and compare
sortedA := make([]string, len(wordsA)) sortedA := make([]string, len(wordsA))
sortedB := make([]string, len(wordsB)) sortedB := make([]string, len(wordsB))
copy(sortedA, wordsA) copy(sortedA, wordsA)
copy(sortedB, wordsB) copy(sortedB, wordsB)
// Simple bubble sort (usually just 2-3 words)
for i := 0; i < len(sortedA)-1; i++ { for i := 0; i < len(sortedA)-1; i++ {
for j := i + 1; j < len(sortedA); j++ { for j := i + 1; j < len(sortedA); j++ {
if sortedA[i] > sortedA[j] { if sortedA[i] > sortedA[j] {
@@ -160,22 +142,18 @@ func qobuzSameWordsUnordered(a, b string) bool {
return true return true
} }
// qobuzTitlesMatch checks if track titles are similar enough
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle))
// Exact match
if normExpected == normFound { if normExpected == normFound {
return true return true
} }
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true return true
} }
// Clean BOTH titles and compare (removes suffixes like remaster, remix, etc)
cleanExpected := qobuzCleanTitle(normExpected) cleanExpected := qobuzCleanTitle(normExpected)
cleanFound := qobuzCleanTitle(normFound) cleanFound := qobuzCleanTitle(normFound)
@@ -183,14 +161,12 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// Check if cleaned versions contain each other
if cleanExpected != "" && cleanFound != "" { if cleanExpected != "" && cleanFound != "" {
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
return true return true
} }
} }
// Extract core title (before any parentheses/brackets)
coreExpected := qobuzExtractCoreTitle(normExpected) coreExpected := qobuzExtractCoreTitle(normExpected)
coreFound := qobuzExtractCoreTitle(normFound) coreFound := qobuzExtractCoreTitle(normFound)
@@ -198,8 +174,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := qobuzIsLatinScript(expectedTitle) expectedLatin := qobuzIsLatinScript(expectedTitle)
foundLatin := qobuzIsLatinScript(foundTitle) foundLatin := qobuzIsLatinScript(foundTitle)
if expectedLatin != foundLatin { if expectedLatin != foundLatin {
@@ -210,9 +184,7 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return false return false
} }
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
func qobuzExtractCoreTitle(title string) string { func qobuzExtractCoreTitle(title string) string {
// Find first occurrence of ( or [
parenIdx := strings.Index(title, "(") parenIdx := strings.Index(title, "(")
bracketIdx := strings.Index(title, "[") bracketIdx := strings.Index(title, "[")
dashIdx := strings.Index(title, " - ") dashIdx := strings.Index(title, " - ")
@@ -231,19 +203,15 @@ func qobuzExtractCoreTitle(title string) string {
return strings.TrimSpace(title[:cutIdx]) return strings.TrimSpace(title[:cutIdx])
} }
// qobuzCleanTitle removes common suffixes from track titles for comparison
func qobuzCleanTitle(title string) string { func qobuzCleanTitle(title string) string {
cleaned := title cleaned := title
// Remove content in parentheses/brackets that are version indicators
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
versionPatterns := []string{ versionPatterns := []string{
"remaster", "remastered", "deluxe", "bonus", "single", "remaster", "remastered", "deluxe", "bonus", "single",
"album version", "radio edit", "original mix", "extended", "album version", "radio edit", "original mix", "extended",
"club mix", "remix", "live", "acoustic", "demo", "club mix", "remix", "live", "acoustic", "demo",
} }
// Remove parenthetical content if it contains version indicators
for { for {
startParen := strings.LastIndex(cleaned, "(") startParen := strings.LastIndex(cleaned, "(")
endParen := strings.LastIndex(cleaned, ")") endParen := strings.LastIndex(cleaned, ")")
@@ -264,7 +232,6 @@ func qobuzCleanTitle(title string) string {
break break
} }
// Same for brackets
for { for {
startBracket := strings.LastIndex(cleaned, "[") startBracket := strings.LastIndex(cleaned, "[")
endBracket := strings.LastIndex(cleaned, "]") endBracket := strings.LastIndex(cleaned, "]")
@@ -285,7 +252,6 @@ func qobuzCleanTitle(title string) string {
break break
} }
// Remove trailing " - version" patterns
dashPatterns := []string{ dashPatterns := []string{
" - remaster", " - remastered", " - single version", " - radio edit", " - remaster", " - remastered", " - single version", " - radio edit",
" - live", " - acoustic", " - demo", " - remix", " - live", " - acoustic", " - demo", " - remix",
@@ -296,7 +262,6 @@ func qobuzCleanTitle(title string) string {
} }
} }
// Remove multiple spaces
for strings.Contains(cleaned, " ") { for strings.Contains(cleaned, " ") {
cleaned = strings.ReplaceAll(cleaned, " ", " ") cleaned = strings.ReplaceAll(cleaned, " ", " ")
} }
@@ -304,49 +269,28 @@ func qobuzCleanTitle(title string) string {
return strings.TrimSpace(cleaned) return strings.TrimSpace(cleaned)
} }
// qobuzIsLatinScript checks if a string is primarily Latin script
// Returns true for ASCII and Latin Extended characters (European languages)
// Returns false for CJK, Arabic, Cyrillic, etc.
func qobuzIsLatinScript(s string) bool { func qobuzIsLatinScript(s string) bool {
for _, r := range s { for _, r := range s {
// Skip common punctuation and numbers
if r < 128 { if r < 128 {
continue continue
} }
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.) if (r >= 0x0100 && r <= 0x024F) ||
// Latin Extended-B: U+0180 to U+024F (r >= 0x1E00 && r <= 0x1EFF) ||
// Latin Extended Additional: U+1E00 to U+1EFF (r >= 0x00C0 && r <= 0x00FF) {
// Latin Extended-C/D/E: various ranges
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
continue continue
} }
// CJK ranges - definitely different script if (r >= 0x4E00 && r <= 0x9FFF) ||
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs (r >= 0x3040 && r <= 0x309F) ||
(r >= 0x3040 && r <= 0x309F) || // Hiragana (r >= 0x30A0 && r <= 0x30FF) ||
(r >= 0x30A0 && r <= 0x30FF) || // Katakana (r >= 0xAC00 && r <= 0xD7AF) ||
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean) (r >= 0x0600 && r <= 0x06FF) ||
(r >= 0x0600 && r <= 0x06FF) || // Arabic (r >= 0x0400 && r <= 0x04FF) {
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
return false return false
} }
} }
return true return true
} }
// qobuzIsASCIIString checks if a string contains only ASCII characters
// Kept for potential future use
// func qobuzIsASCIIString(s string) bool {
// for _, r := range s {
// if r > 127 {
// return false
// }
// }
// return true
// }
// containsQueryQobuz checks if a query already exists in the list
func containsQueryQobuz(queries []string, query string) bool { func containsQueryQobuz(queries []string, query string) bool {
for _, q := range queries { for _, q := range queries {
if q == query { if q == query {
@@ -356,25 +300,48 @@ func containsQueryQobuz(queries []string, query string) bool {
return false return false
} }
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
func NewQobuzDownloader() *QobuzDownloader { func NewQobuzDownloader() *QobuzDownloader {
qobuzDownloaderOnce.Do(func() { qobuzDownloaderOnce.Do(func() {
globalQobuzDownloader = &QobuzDownloader{ globalQobuzDownloader = &QobuzDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout client: NewHTTPClientWithTimeout(DefaultTimeout),
appID: "798273057", appID: "798273057",
} }
}) })
return globalQobuzDownloader return globalQobuzDownloader
} }
// GetAvailableAPIs returns list of available Qobuz APIs func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
// Uses same APIs as PC version for compatibility apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
req, err := http.NewRequest("GET", trackURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("get track failed: HTTP %d", resp.StatusCode)
}
var track QobuzTrack
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
return nil, err
}
return &track, nil
}
func (q *QobuzDownloader) GetAvailableAPIs() []string { func (q *QobuzDownloader) GetAvailableAPIs() []string {
// Same APIs as PC version (referensi/backend/qobuz.go)
// Primary: dab.yeet.su, Fallback: dabmusic.xyz
encodedAPIs := []string{ encodedAPIs := []string{
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC) "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==",
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC) "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=",
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=",
} }
var apis []string var apis []string
@@ -389,7 +356,86 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
return apis return apis
} }
// SearchTrackByISRC searches for a track by ISRC func mapJumoQuality(quality string) int {
switch quality {
case "6":
return 6
case "7":
return 7
case "27":
return 27
default:
return 6
}
}
func decodeXOR(data []byte) string {
text := string(data)
runes := []rune(text)
result := make([]rune, len(runes))
for i, char := range runes {
key := rune((i * 17) % 128)
result[i] = char ^ 253 ^ key
}
return string(result)
}
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
formatID := mapJumoQuality(quality)
region := "US"
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
GoLog("[Qobuz] Trying Jumo API fallback...\n")
client := NewHTTPClientWithTimeout(30 * time.Second)
req, err := http.NewRequest("GET", jumoURL, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("Jumo API returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
decoded := decodeXOR(body)
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err)
}
}
if urlVal, ok := result["url"].(string); ok && urlVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully\n")
return urlVal, nil
}
if data, ok := result["data"].(map[string]any); ok {
if urlVal, ok := data["url"].(string); ok && urlVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully (from data)\n")
return urlVal, nil
}
}
if linkVal, ok := result["link"].(string); ok && linkVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully (from link)\n")
return linkVal, nil
}
return "", fmt.Errorf("URL not found in Jumo response")
}
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
@@ -418,7 +464,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, err return nil, err
} }
// Find exact ISRC match
for i := range result.Tracks.Items { for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc { if result.Tracks.Items[i].ISRC == isrc {
return &result.Tracks.Items[i], nil return &result.Tracks.Items[i], nil
@@ -432,8 +477,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
} }
// 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) { func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
@@ -466,7 +509,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items)) GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
// Find ISRC matches
var isrcMatches []*QobuzTrack var isrcMatches []*QobuzTrack
for i := range result.Tracks.Items { for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc { if result.Tracks.Items[i].ISRC == isrc {
@@ -477,7 +519,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches)) GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
if len(isrcMatches) > 0 { if len(isrcMatches) > 0 {
// Verify duration if provided
if expectedDurationSec > 0 { if expectedDurationSec > 0 {
var durationVerifiedMatches []*QobuzTrack var durationVerifiedMatches []*QobuzTrack
for _, track := range isrcMatches { for _, track := range isrcMatches {
@@ -485,7 +526,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
if durationDiff < 0 { if durationDiff < 0 {
durationDiff = -durationDiff durationDiff = -durationDiff
} }
// Allow 10 seconds tolerance
if durationDiff <= 10 { if durationDiff <= 10 {
durationVerifiedMatches = append(durationVerifiedMatches, track) durationVerifiedMatches = append(durationVerifiedMatches, track)
} }
@@ -497,14 +537,12 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return durationVerifiedMatches[0], nil 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", GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
isrc, expectedDurationSec, isrcMatches[0].Duration) isrc, expectedDurationSec, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)", return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
expectedDurationSec, isrcMatches[0].Duration) expectedDurationSec, isrcMatches[0].Duration)
} }
// No duration to verify, return first match
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil return isrcMatches[0], nil
} }
@@ -516,46 +554,34 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) 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) { func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
return q.SearchTrackByISRCWithDuration(isrc, 0) 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) { func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0) 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) { func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
// Try multiple search strategies (same as Tidal/PC version)
queries := []string{} queries := []string{}
// Strategy 1: Artist + Track name
if artistName != "" && trackName != "" { if artistName != "" && trackName != "" {
queries = append(queries, artistName+" "+trackName) queries = append(queries, artistName+" "+trackName)
} }
// Strategy 2: Track name only
if trackName != "" { if trackName != "" {
queries = append(queries, trackName) queries = append(queries, trackName)
} }
// Strategy 3: Romaji versions if Japanese detected
if ContainsJapanese(trackName) || ContainsJapanese(artistName) { if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
// Convert to romaji (hiragana/katakana only, kanji stays)
romajiTrack := JapaneseToRomaji(trackName) romajiTrack := JapaneseToRomaji(trackName)
romajiArtist := JapaneseToRomaji(artistName) romajiArtist := JapaneseToRomaji(artistName)
// Clean and remove ALL non-ASCII characters (including kanji)
cleanRomajiTrack := CleanToASCII(romajiTrack) cleanRomajiTrack := CleanToASCII(romajiTrack)
cleanRomajiArtist := CleanToASCII(romajiArtist) cleanRomajiArtist := CleanToASCII(romajiArtist)
// Artist + Track romaji (cleaned to ASCII only)
if cleanRomajiArtist != "" && cleanRomajiTrack != "" { if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
if !containsQueryQobuz(queries, romajiQuery) { if !containsQueryQobuz(queries, romajiQuery) {
@@ -564,7 +590,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
} }
// Track romaji only (cleaned)
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName { if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
if !containsQueryQobuz(queries, cleanRomajiTrack) { if !containsQueryQobuz(queries, cleanRomajiTrack) {
queries = append(queries, cleanRomajiTrack) queries = append(queries, cleanRomajiTrack)
@@ -572,7 +597,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
} }
// Strategy 4: Artist only as last resort
if artistName != "" { if artistName != "" {
artistOnly := CleanToASCII(JapaneseToRomaji(artistName)) artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) { if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
@@ -631,7 +655,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName) return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
} }
// Filter by title match first (NEW - like Tidal)
var titleMatches []*QobuzTrack var titleMatches []*QobuzTrack
for i := range allTracks { for i := range allTracks {
track := &allTracks[i] track := &allTracks[i]
@@ -642,7 +665,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks)) GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
// If no title matches, log warning but continue with all tracks
tracksToCheck := titleMatches tracksToCheck := titleMatches
if len(titleMatches) == 0 { if len(titleMatches) == 0 {
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks)) GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
@@ -651,7 +673,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
} }
// If duration verification is requested
if expectedDurationSec > 0 { if expectedDurationSec > 0 {
var durationMatches []*QobuzTrack var durationMatches []*QobuzTrack
for _, track := range tracksToCheck { for _, track := range tracksToCheck {
@@ -665,34 +686,31 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
if len(durationMatches) > 0 { if len(durationMatches) > 0 {
// Return best quality among duration matches
for _, track := range durationMatches { for _, track := range durationMatches {
if track.MaximumBitDepth >= 24 { if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified, hi-res)\n", GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
track.Title, track.Performer.Name) track.Title, track.Performer.Name)
return track, nil return track, nil
} }
} }
GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified)\n", GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified)\n",
durationMatches[0].Title, durationMatches[0].Performer.Name) durationMatches[0].Title, durationMatches[0].Performer.Name)
return durationMatches[0], nil return durationMatches[0], nil
} }
// No duration match found
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec) return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
} }
// No duration verification, return best quality from title matches
for _, track := range tracksToCheck { for _, track := range tracksToCheck {
if track.MaximumBitDepth >= 24 { if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n", GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n",
track.Title, track.Performer.Name) track.Title, track.Performer.Name)
return track, nil return track, nil
} }
} }
if len(tracksToCheck) > 0 { if len(tracksToCheck) > 0 {
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified)\n", GoLog("[Qobuz] Match found: '%s' by '%s' (title verified)\n",
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name) tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
return tracksToCheck[0], nil return tracksToCheck[0], nil
} }
@@ -700,7 +718,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
} }
// qobuzAPIResult holds the result from a parallel API request
type qobuzAPIResult struct { type qobuzAPIResult struct {
apiURL string apiURL string
downloadURL string downloadURL string
@@ -708,8 +725,6 @@ type qobuzAPIResult struct {
duration time.Duration 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) { func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 { if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available") return "", "", fmt.Errorf("no APIs available")
@@ -720,14 +735,11 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
resultChan := make(chan qobuzAPIResult, len(apis)) resultChan := make(chan qobuzAPIResult, len(apis))
startTime := time.Now() startTime := time.Now()
// Start all requests in parallel
for _, apiURL := range apis { for _, apiURL := range apis {
go func(api string) { go func(api string) {
reqStart := time.Now() reqStart := time.Now()
client := &http.Client{ client := NewHTTPClientWithTimeout(15 * time.Second)
Timeout: 15 * time.Second,
}
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality) reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
@@ -755,13 +767,11 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
return return
} }
// Check if response is HTML (error page)
if len(body) > 0 && body[0] == '<' { if len(body) > 0 && body[0] == '<' {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)} resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
return return
} }
// Check for error in JSON response
var errorResp struct { var errorResp struct {
Error string `json:"error"` Error string `json:"error"`
} }
@@ -787,15 +797,13 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
}(apiURL) }(apiURL)
} }
// Collect results - return first success
var errors []string var errors []string
for i := 0; i < len(apis); i++ { for i := 0; i < len(apis); i++ {
result := <-resultChan result := <-resultChan
if result.err == nil { if result.err == nil {
GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration) GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration)
// Drain remaining results to avoid goroutine leaks
go func(remaining int) { go func(remaining int) {
for j := 0; j < remaining; j++ { for j := 0; j < remaining; j++ {
<-resultChan <-resultChan
@@ -816,38 +824,66 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors) 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) { func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
apis := q.GetAvailableAPIs() apis := q.GetAvailableAPIs()
if len(apis) == 0 { if len(apis) == 0 {
return "", fmt.Errorf("no Qobuz API available") return "", fmt.Errorf("no Qobuz API available")
} }
// Use parallel approach - request from all APIs simultaneously
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality) _, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
if err != nil { if err == nil {
return "", err return downloadURL, nil
} }
return downloadURL, nil GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n")
jumoURL, jumoErr := q.downloadFromJumo(trackID, quality)
if jumoErr == nil {
return jumoURL, nil
}
if quality == "27" {
GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n")
jumoURL, jumoErr = q.downloadFromJumo(trackID, "7")
if jumoErr == nil {
return jumoURL, nil
}
}
if quality == "27" || quality == "7" {
GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n")
jumoURL, jumoErr = q.downloadFromJumo(trackID, "6")
if jumoErr == nil {
return jumoURL, nil
}
}
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
} }
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Initialize item progress (required for all downloads) ctx := context.Background()
if itemID != "" { if itemID != "" {
StartItemProgress(itemID) StartItemProgress(itemID)
defer CompleteItemProgress(itemID) defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
} }
req, err := http.NewRequest("GET", downloadURL, nil) if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
} }
resp, err := DoRequestWithUserAgent(q.client, req) resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil { if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -857,7 +893,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
} }
expectedSize := resp.ContentLength expectedSize := resp.ContentLength
// Set total bytes if available
if expectedSize > 0 && itemID != "" { if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize) SetItemBytesTotal(itemID, expectedSize)
} }
@@ -867,26 +902,24 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return err return err
} }
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024) bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64 var written int64
if itemID != "" { if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID) progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body) written, err = io.Copy(progressWriter, resp.Body)
} else { } else {
// Fallback: direct copy without progress tracking
written, err = io.Copy(bufWriter, resp.Body) written, err = io.Copy(bufWriter, resp.Body)
} }
// Flush buffer before checking for errors
flushErr := bufWriter.Flush() flushErr := bufWriter.Flush()
closeErr := out.Close() closeErr := out.Close()
// Check for any errors
if err != nil { if err != nil {
os.Remove(outputPath) os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err) return fmt.Errorf("download interrupted: %w", err)
} }
if flushErr != nil { if flushErr != nil {
@@ -898,7 +931,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return fmt.Errorf("failed to close file: %w", closeErr) return fmt.Errorf("failed to close file: %w", closeErr)
} }
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize { if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath) os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
@@ -907,7 +939,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return nil return nil
} }
// QobuzDownloadResult contains download result with quality info
type QobuzDownloadResult struct { type QobuzDownloadResult struct {
FilePath string FilePath string
BitDepth int BitDepth int
@@ -921,26 +952,35 @@ type QobuzDownloadResult struct {
ISRC string ISRC string
} }
// downloadFromQobuz downloads a track using the request parameters
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
downloader := NewQobuzDownloader() downloader := NewQobuzDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
} }
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000 expectedDurationSec := req.DurationMS / 1000
var track *QobuzTrack var track *QobuzTrack
var err error var err error
// OPTIMIZATION: Check cache first for track ID if req.QobuzID != "" {
if req.ISRC != "" { GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
var trackID int64
if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
track, err = downloader.GetTrackByID(trackID)
if err != nil {
GoLog("[Qobuz] Failed to get track by Odesli ID %d: %v\n", trackID, err)
track = nil
} else if track != nil {
GoLog("[Qobuz] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
}
}
}
if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID) GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
// For Qobuz we need to search again to get full track info, but we can use the ID
track, err = downloader.SearchTrackByISRC(req.ISRC) track, err = downloader.SearchTrackByISRC(req.ISRC)
if err != nil { if err != nil {
GoLog("[Qobuz] Cache hit but search failed: %v\n", err) GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
@@ -949,11 +989,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
} }
} }
// Strategy 1: Search by ISRC with duration verification
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC) GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
// Verify artist AND title
if track != nil { if track != nil {
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
@@ -967,10 +1005,8 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
} }
} }
// Strategy 2: Search by metadata with duration verification (includes title verification)
if track == nil { if track == nil {
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
// Verify artist (title already verified in SearchTrackByMetadataWithDuration)
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name) req.ArtistName, track.Performer.Name)
@@ -986,13 +1022,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
} }
// Log match found and cache the track ID
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration) GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
if req.ISRC != "" { if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID) GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
} }
// Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName, "title": req.TrackName,
"artist": req.ArtistName, "artist": req.ArtistName,
@@ -1004,37 +1038,30 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
filename = sanitizeFilename(filename) + ".flac" filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename) outputPath := filepath.Join(req.OutputDir, filename)
// Check if file already exists
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
} }
// Map quality from Tidal format to Qobuz format qobuzQuality := "27"
// Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res)
// Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz)
qobuzQuality := "27" // Default to highest quality
switch req.Quality { switch req.Quality {
case "LOSSLESS": case "LOSSLESS":
qobuzQuality = "6" // 16-bit FLAC qobuzQuality = "6"
case "HI_RES": case "HI_RES":
qobuzQuality = "7" // 24-bit 96kHz qobuzQuality = "7"
case "HI_RES_LOSSLESS": case "HI_RES_LOSSLESS":
qobuzQuality = "27" // 24-bit 192kHz qobuzQuality = "27"
} }
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
// Get actual quality from track metadata
actualBitDepth := track.MaximumBitDepth actualBitDepth := track.MaximumBitDepth
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz actualSampleRate := int(track.MaximumSamplingRate * 1000)
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate) GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
// Get download URL using parallel API requests
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality) downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
if err != nil { if err != nil {
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
} }
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{}) parallelDone := make(chan struct{})
go func() { go func() {
@@ -1046,45 +1073,49 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
req.TrackName, req.TrackName,
req.ArtistName, req.ArtistName,
req.EmbedLyrics, req.EmbedLyrics,
int64(req.DurationMS),
) )
}() }()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return QobuzDownloadResult{}, ErrDownloadCancelled
}
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err) return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
} }
// Wait for parallel operations to complete
<-parallelDone <-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" { if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0) SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID) SetItemFinalizing(req.ItemID)
} }
// Embed metadata using parallel-fetched cover data
// Use metadata from the actual Qobuz track found (more accurate than request) but prefer
// requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original)
albumName := track.Album.Title albumName := track.Album.Title
if req.AlbumName != "" { if req.AlbumName != "" {
albumName = req.AlbumName albumName = req.AlbumName
} }
actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 {
actualTrackNumber = track.TrackNumber
}
metadata := Metadata{ metadata := Metadata{
Title: track.Title, Title: track.Title,
Artist: track.Performer.Name, Artist: track.Performer.Name,
Album: albumName, Album: albumName,
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct AlbumArtist: req.AlbumArtist,
Date: track.Album.ReleaseDate, Date: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber, TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result DiscNumber: req.DiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
} }
// Use cover data from parallel fetch
var coverData []byte var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil { if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData coverData = parallelResult.CoverData
@@ -1095,19 +1126,33 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
fmt.Printf("Warning: failed to embed metadata: %v\n", err) fmt.Printf("Warning: failed to embed metadata: %v\n", err)
} }
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) lyricsMode := req.LyricsMode
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { if lyricsMode == "" {
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) lyricsMode = "embed"
} else { }
fmt.Println("[Qobuz] Lyrics embedded successfully")
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 { } else if req.EmbedLyrics {
fmt.Println("[Qobuz] No lyrics available from parallel fetch") fmt.Println("[Qobuz] No lyrics available from parallel fetch")
} }
// Add to ISRC index for fast duplicate checking
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
return QobuzDownloadResult{ return QobuzDownloadResult{
@@ -1118,8 +1163,8 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Artist: track.Performer.Name, Artist: track.Performer.Name,
Album: track.Album.Title, Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate, ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber, TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber, // Qobuz track struct limitations DiscNumber: req.DiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
}, nil }, nil
} }
-14
View File
@@ -5,7 +5,6 @@ import (
"time" "time"
) )
// RateLimiter implements a sliding window rate limiter
type RateLimiter struct { type RateLimiter struct {
mu sync.Mutex mu sync.Mutex
maxRequests int maxRequests int
@@ -13,7 +12,6 @@ type RateLimiter struct {
timestamps []time.Time timestamps []time.Time
} }
// NewRateLimiter creates a new rate limiter with specified max requests per window
func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter { func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
return &RateLimiter{ return &RateLimiter{
maxRequests: maxRequests, maxRequests: maxRequests,
@@ -22,39 +20,31 @@ 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() { func (r *RateLimiter) WaitForSlot() {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
now := time.Now() now := time.Now()
// Remove timestamps outside the window
r.cleanOldTimestamps(now) r.cleanOldTimestamps(now)
// If under limit, record and return immediately
if len(r.timestamps) < r.maxRequests { if len(r.timestamps) < r.maxRequests {
r.timestamps = append(r.timestamps, now) r.timestamps = append(r.timestamps, now)
return return
} }
// Calculate wait time until oldest timestamp expires
oldestTimestamp := r.timestamps[0] oldestTimestamp := r.timestamps[0]
waitUntil := oldestTimestamp.Add(r.window) waitUntil := oldestTimestamp.Add(r.window)
waitDuration := waitUntil.Sub(now) waitDuration := waitUntil.Sub(now)
if waitDuration > 0 { if waitDuration > 0 {
// Release lock while waiting
r.mu.Unlock() r.mu.Unlock()
time.Sleep(waitDuration) time.Sleep(waitDuration)
r.mu.Lock() r.mu.Lock()
// Clean again after waiting
r.cleanOldTimestamps(time.Now()) r.cleanOldTimestamps(time.Now())
} }
// Record this request
r.timestamps = append(r.timestamps, time.Now()) r.timestamps = append(r.timestamps, time.Now())
} }
@@ -76,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 { func (r *RateLimiter) TryAcquire() bool {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -93,7 +81,6 @@ func (r *RateLimiter) TryAcquire() bool {
return false return false
} }
// Available returns the number of requests available in the current window
func (r *RateLimiter) Available() int { func (r *RateLimiter) Available() int {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -105,7 +92,6 @@ func (r *RateLimiter) Available() int {
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10) // Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
var songLinkRateLimiter = NewRateLimiter(9, time.Minute) var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
// GetSongLinkRateLimiter returns the global SongLink rate limiter
func GetSongLinkRateLimiter() *RateLimiter { func GetSongLinkRateLimiter() *RateLimiter {
return songLinkRateLimiter return songLinkRateLimiter
} }
-11
View File
@@ -5,7 +5,6 @@ import (
"unicode" "unicode"
) )
// Hiragana to Romaji mapping
var hiraganaToRomaji = map[rune]string{ var hiraganaToRomaji = map[rune]string{
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o", 'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko", 'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
@@ -30,7 +29,6 @@ var hiraganaToRomaji = map[rune]string{
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o", 'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
} }
// Katakana to Romaji mapping
var katakanaToRomaji = map[rune]string{ var katakanaToRomaji = map[rune]string{
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o", 'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko", 'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
@@ -58,7 +56,6 @@ var katakanaToRomaji = map[rune]string{
'ヴ': "vu", 'ヴ': "vu",
} }
// Combination mappings for きゃ, しゃ, etc.
var combinationHiragana = map[string]string{ var combinationHiragana = map[string]string{
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo", "きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
"しゃ": "sha", "しゅ": "shu", "しょ": "sho", "しゃ": "sha", "しゅ": "shu", "しょ": "sho",
@@ -91,7 +88,6 @@ var combinationKatakana = map[string]string{
"ウィ": "wi", "ウェ": "we", "ウォ": "wo", "ウィ": "wi", "ウェ": "we", "ウォ": "wo",
} }
// ContainsJapanese checks if a string contains Japanese characters
func ContainsJapanese(s string) bool { func ContainsJapanese(s string) bool {
for _, r := range s { for _, r := range s {
if isHiragana(r) || isKatakana(r) || isKanji(r) { 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 (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 { func JapaneseToRomaji(text string) string {
if !ContainsJapanese(text) { if !ContainsJapanese(text) {
return text return text
@@ -175,8 +169,6 @@ func JapaneseToRomaji(text string) string {
return result.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 { func BuildSearchQuery(trackName, artistName string) string {
// Convert Japanese to romaji // Convert Japanese to romaji
trackRomaji := JapaneseToRomaji(trackName) trackRomaji := JapaneseToRomaji(trackName)
@@ -189,7 +181,6 @@ func BuildSearchQuery(trackName, artistName string) string {
return strings.TrimSpace(artistClean + " " + trackClean) return strings.TrimSpace(artistClean + " " + trackClean)
} }
// cleanSearchQuery removes special characters that might interfere with search
func cleanSearchQuery(s string) string { func cleanSearchQuery(s string) string {
var result strings.Builder var result strings.Builder
for _, r := range s { for _, r := range s {
@@ -202,8 +193,6 @@ func cleanSearchQuery(s string) string {
return strings.TrimSpace(result.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 { func CleanToASCII(s string) string {
var result strings.Builder var result strings.Builder
for _, r := range s { for _, r := range s {
+29 -72
View File
@@ -11,12 +11,10 @@ import (
"time" "time"
) )
// SongLinkClient handles song.link API interactions
type SongLinkClient struct { type SongLinkClient struct {
client *http.Client client *http.Client
} }
// TrackAvailability represents track availability on different platforms
type TrackAvailability struct { type TrackAvailability struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"` Tidal bool `json:"tidal"`
@@ -31,32 +29,22 @@ type TrackAvailability struct {
} }
var ( var (
// Global SongLink client instance for connection reuse
globalSongLinkClient *SongLinkClient globalSongLinkClient *SongLinkClient
songLinkClientOnce sync.Once songLinkClientOnce sync.Once
) )
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
func NewSongLinkClient() *SongLinkClient { func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() { songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{ globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout client: NewHTTPClientWithTimeout(SongLinkTimeout),
} }
}) })
return globalSongLinkClient return globalSongLinkClient
} }
// CheckTrackAvailability checks track availability on streaming platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
// Validate Spotify ID format (should be 22 characters alphanumeric)
if spotifyTrackID == "" {
return nil, fmt.Errorf("spotify track ID is empty")
}
// Use global rate limiter - blocks until request is allowed
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
// Build API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -68,7 +56,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
} }
// Use retry logic with User-Agent
retryConfig := DefaultRetryConfig() retryConfig := DefaultRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig) resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil { if err != nil {
@@ -76,7 +63,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
} }
defer resp.Body.Close() defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 { if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)") return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
} }
@@ -109,35 +95,25 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
SpotifyID: spotifyTrackID, SpotifyID: spotifyTrackID,
} }
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true availability.Tidal = true
availability.TidalURL = tidalLink.URL availability.TidalURL = tidalLink.URL
} }
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true availability.Amazon = true
availability.AmazonURL = amazonLink.URL availability.AmazonURL = amazonLink.URL
} }
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true availability.Deezer = true
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
} }
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
return availability, nil return availability, nil
} }
// GetStreamingURLs gets streaming URLs for a Spotify track
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) { func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "") availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil { if err != nil {
@@ -191,12 +167,9 @@ func checkQobuzAvailability(isrc string) bool {
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL // extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
func extractDeezerIDFromURL(deezerURL string) string { func extractDeezerIDFromURL(deezerURL string) string {
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
parts := strings.Split(deezerURL, "/") parts := strings.Split(deezerURL, "/")
if len(parts) > 0 { if len(parts) > 0 {
// Get the last part which should be the ID
lastPart := parts[len(parts)-1] lastPart := parts[len(parts)-1]
// Remove any query parameters
if idx := strings.Index(lastPart, "?"); idx > 0 { if idx := strings.Index(lastPart, "?"); idx > 0 {
lastPart = lastPart[:idx] lastPart = lastPart[:idx]
} }
@@ -205,17 +178,16 @@ func extractDeezerIDFromURL(deezerURL string) string {
return "" return ""
} }
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "") availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil { if err != nil {
return "", err return "", err
} }
if !availability.Deezer || availability.DeezerID == "" { if !availability.Deezer || availability.DeezerID == "" {
return "", fmt.Errorf("track not found on Deezer") return "", fmt.Errorf("track not found on Deezer")
} }
return availability.DeezerID, nil return availability.DeezerID, nil
} }
@@ -227,12 +199,9 @@ type AlbumAvailability struct {
DeezerID string `json:"deezer_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"`
} }
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
// Use global rate limiter
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
// Build API URL for album
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID) spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
@@ -274,7 +243,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
SpotifyID: spotifyAlbumID, SpotifyID: spotifyAlbumID,
} }
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true availability.Deezer = true
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
@@ -290,32 +258,40 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
if err != nil { if err != nil {
return "", err return "", err
} }
if !availability.Deezer || availability.DeezerID == "" { if !availability.Deezer || availability.DeezerID == "" {
return "", fmt.Errorf("album not found on Deezer") return "", fmt.Errorf("album not found on Deezer")
} }
return availability.DeezerID, nil return availability.DeezerID, nil
} }
// ========================================
// 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 // 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) { func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
if deezerTrackID == "" { if deezerTrackID == "" {
return nil, fmt.Errorf("deezer track ID is empty") return nil, fmt.Errorf("deezer track ID is empty")
} }
// Use global rate limiter availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
if err != nil {
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
idhsClient := NewIDHSClient()
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
if err != nil {
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
}
LogInfo("SongLink", "IDHS fallback successful for Deezer %s", deezerTrackID)
}
return availability, nil
}
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
// Build Deezer URL
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
// Build API URL using Deezer URL as source
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL)) apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
@@ -331,7 +307,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
} }
defer resp.Body.Close() defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 { if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)") return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
} }
@@ -371,25 +346,20 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
DeezerID: deezerTrackID, DeezerID: deezerTrackID,
} }
// Check Spotify
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" { if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
// Extract Spotify ID from URL
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL) availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
} }
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true availability.Tidal = true
availability.TidalURL = tidalLink.URL availability.TidalURL = tidalLink.URL
} }
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true availability.Amazon = true
availability.AmazonURL = amazonLink.URL availability.AmazonURL = amazonLink.URL
} }
// Check Deezer URL
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
} }
@@ -397,7 +367,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return availability, nil return availability, nil
} }
// CheckAvailabilityByPlatform checks track availability using any supported platform
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc. // platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
// entityType: "song" or "album" // entityType: "song" or "album"
// entityID: the ID on that platform // entityID: the ID on that platform
@@ -405,12 +374,9 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
if entityID == "" { if entityID == "" {
return nil, fmt.Errorf("%s ID is empty", platform) return nil, fmt.Errorf("%s ID is empty", platform)
} }
// Use global rate limiter
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
// Build API URL using platform, type, and id parameters (as per API docs)
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US", apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
url.QueryEscape(platform), url.QueryEscape(platform),
url.QueryEscape(entityType), url.QueryEscape(entityType),
@@ -428,7 +394,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
} }
defer resp.Body.Close() defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 { if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform) return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
} }
@@ -459,24 +424,20 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
availability := &TrackAvailability{} availability := &TrackAvailability{}
// Check Spotify
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" { if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL) availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
} }
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true availability.Tidal = true
availability.TidalURL = tidalLink.URL availability.TidalURL = tidalLink.URL
} }
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true availability.Amazon = true
availability.AmazonURL = amazonLink.URL availability.AmazonURL = amazonLink.URL
} }
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true availability.Deezer = true
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
@@ -488,10 +449,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
// extractSpotifyIDFromURL extracts Spotify track ID from URL // extractSpotifyIDFromURL extracts Spotify track ID from URL
func extractSpotifyIDFromURL(spotifyURL string) string { func extractSpotifyIDFromURL(spotifyURL string) string {
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
parts := strings.Split(spotifyURL, "/track/") parts := strings.Split(spotifyURL, "/track/")
if len(parts) > 1 { if len(parts) > 1 {
// Get the ID part and remove any query parameters
idPart := parts[1] idPart := parts[1]
if idx := strings.Index(idPart, "?"); idx > 0 { if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx] idPart = idPart[:idx]
@@ -501,17 +460,16 @@ func extractSpotifyIDFromURL(spotifyURL string) string {
return "" return ""
} }
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) { func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil { if err != nil {
return "", err return "", err
} }
if availability.SpotifyID == "" { if availability.SpotifyID == "" {
return "", fmt.Errorf("track not found on Spotify") return "", fmt.Errorf("track not found on Spotify")
} }
return availability.SpotifyID, nil return availability.SpotifyID, nil
} }
@@ -521,24 +479,23 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
if err != nil { if err != nil {
return "", err return "", err
} }
if !availability.Tidal || availability.TidalURL == "" { if !availability.Tidal || availability.TidalURL == "" {
return "", fmt.Errorf("track not found on Tidal") return "", fmt.Errorf("track not found on Tidal")
} }
return availability.TidalURL, nil return availability.TidalURL, nil
} }
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) { func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil { if err != nil {
return "", err return "", err
} }
if !availability.Amazon || availability.AmazonURL == "" { if !availability.Amazon || availability.AmazonURL == "" {
return "", fmt.Errorf("track not found on Amazon Music") return "", fmt.Errorf("track not found on Amazon Music")
} }
return availability.AmazonURL, nil return availability.AmazonURL, nil
} }
+43 -78
View File
@@ -24,7 +24,6 @@ const (
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
searchBaseURL = "https://api.spotify.com/v1/search" searchBaseURL = "https://api.spotify.com/v1/search"
// Cache TTL settings
artistCacheTTL = 10 * time.Minute artistCacheTTL = 10 * time.Minute
searchCacheTTL = 5 * time.Minute searchCacheTTL = 5 * time.Minute
albumCacheTTL = 10 * time.Minute albumCacheTTL = 10 * time.Minute
@@ -32,7 +31,6 @@ const (
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL") var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
// cacheEntry holds cached data with expiration
type cacheEntry struct { type cacheEntry struct {
data interface{} data interface{}
expiresAt time.Time expiresAt time.Time
@@ -42,26 +40,23 @@ func (e *cacheEntry) isExpired() bool {
return time.Now().After(e.expiresAt) return time.Now().After(e.expiresAt)
} }
// SpotifyMetadataClient handles Spotify API interactions
type SpotifyMetadataClient struct { type SpotifyMetadataClient struct {
httpClient *http.Client httpClient *http.Client
clientID string clientID string
clientSecret string clientSecret string
cachedToken string cachedToken string
tokenExpiresAt time.Time tokenExpiresAt time.Time
tokenMu sync.Mutex // Protects token cache for concurrent access tokenMu sync.Mutex
rng *rand.Rand rng *rand.Rand
rngMu sync.Mutex rngMu sync.Mutex
userAgent string userAgent string
// Caches to reduce API calls artistCache map[string]*cacheEntry
artistCache map[string]*cacheEntry // key: artistID searchCache map[string]*cacheEntry
searchCache map[string]*cacheEntry // key: query+type albumCache map[string]*cacheEntry
albumCache map[string]*cacheEntry // key: albumID
cacheMu sync.RWMutex cacheMu sync.RWMutex
} }
// Custom credentials storage (set from Flutter)
var ( var (
customClientID string customClientID string
customClientSecret string customClientSecret string
@@ -71,7 +66,6 @@ var (
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured // ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)") var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
// SetSpotifyCredentials sets custom Spotify API credentials
func SetSpotifyCredentials(clientID, clientSecret string) { func SetSpotifyCredentials(clientID, clientSecret string) {
credentialsMu.Lock() credentialsMu.Lock()
defer credentialsMu.Unlock() defer credentialsMu.Unlock()
@@ -79,17 +73,14 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
customClientSecret = clientSecret customClientSecret = clientSecret
} }
// HasSpotifyCredentials checks if Spotify credentials are configured
func HasSpotifyCredentials() bool { func HasSpotifyCredentials() bool {
credentialsMu.RLock() credentialsMu.RLock()
defer credentialsMu.RUnlock() defer credentialsMu.RUnlock()
// Check custom credentials first
if customClientID != "" && customClientSecret != "" { if customClientID != "" && customClientSecret != "" {
return true return true
} }
// Check environment variables
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" { if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
return true return true
} }
@@ -97,17 +88,14 @@ func HasSpotifyCredentials() bool {
return false return false
} }
// getCredentials returns the current credentials or error if not configured
func getCredentials() (string, string, error) { func getCredentials() (string, string, error) {
credentialsMu.RLock() credentialsMu.RLock()
defer credentialsMu.RUnlock() defer credentialsMu.RUnlock()
// Check custom credentials first
if customClientID != "" && customClientSecret != "" { if customClientID != "" && customClientSecret != "" {
return customClientID, customClientSecret, nil return customClientID, customClientSecret, nil
} }
// Check environment variables
clientID := os.Getenv("SPOTIFY_CLIENT_ID") clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
@@ -115,14 +103,10 @@ func getCredentials() (string, string, error) {
return clientID, clientSecret, nil return clientID, clientSecret, nil
} }
// No credentials available
return "", "", ErrNoSpotifyCredentials return "", "", ErrNoSpotifyCredentials
} }
// NewSpotifyMetadataClient creates a new Spotify client
// Returns error if credentials are not configured
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) { func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
// Get credentials - will error if not configured
clientID, clientSecret, err := getCredentials() clientID, clientSecret, err := getCredentials()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -131,7 +115,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
src := rand.NewSource(time.Now().UnixNano()) src := rand.NewSource(time.Now().UnixNano())
c := &SpotifyMetadataClient{ c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling httpClient: NewHTTPClientWithTimeout(15 * time.Second),
clientID: clientID, clientID: clientID,
clientSecret: clientSecret, clientSecret: clientSecret,
rng: rand.New(src), rng: rand.New(src),
@@ -143,7 +127,6 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
return c, nil return c, nil
} }
// TrackMetadata represents track information
type TrackMetadata struct { type TrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"` SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"` Artists string `json:"artists"`
@@ -161,7 +144,6 @@ type TrackMetadata struct {
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
} }
// AlbumTrackMetadata holds per-track info for album/playlist
type AlbumTrackMetadata struct { type AlbumTrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"` SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"` Artists string `json:"artists"`
@@ -178,25 +160,26 @@ type AlbumTrackMetadata struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
AlbumID string `json:"album_id,omitempty"` AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,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 { type AlbumInfoMetadata struct {
TotalTracks int `json:"total_tracks"` TotalTracks int `json:"total_tracks"`
Name string `json:"name"` Name string `json:"name"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
Artists string `json:"artists"` Artists string `json:"artists"`
ArtistId string `json:"artist_id,omitempty"`
Images string `json:"images"` 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 { type AlbumResponsePayload struct {
AlbumInfo AlbumInfoMetadata `json:"album_info"` AlbumInfo AlbumInfoMetadata `json:"album_info"`
TrackList []AlbumTrackMetadata `json:"track_list"` TrackList []AlbumTrackMetadata `json:"track_list"`
} }
// PlaylistInfoMetadata holds playlist information
type PlaylistInfoMetadata struct { type PlaylistInfoMetadata struct {
Tracks struct { Tracks struct {
Total int `json:"total"` Total int `json:"total"`
@@ -208,13 +191,11 @@ type PlaylistInfoMetadata struct {
} `json:"owner"` } `json:"owner"`
} }
// PlaylistResponsePayload is the response for playlist requests
type PlaylistResponsePayload struct { type PlaylistResponsePayload struct {
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"` PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
TrackList []AlbumTrackMetadata `json:"track_list"` TrackList []AlbumTrackMetadata `json:"track_list"`
} }
// ArtistInfoMetadata holds artist information
type ArtistInfoMetadata struct { type ArtistInfoMetadata struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -223,7 +204,6 @@ type ArtistInfoMetadata struct {
Popularity int `json:"popularity"` Popularity int `json:"popularity"`
} }
// ArtistAlbumMetadata holds album info for artist discography
type ArtistAlbumMetadata struct { type ArtistAlbumMetadata struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -234,24 +214,20 @@ type ArtistAlbumMetadata struct {
Artists string `json:"artists"` Artists string `json:"artists"`
} }
// ArtistResponsePayload is the response for artist requests
type ArtistResponsePayload struct { type ArtistResponsePayload struct {
ArtistInfo ArtistInfoMetadata `json:"artist_info"` ArtistInfo ArtistInfoMetadata `json:"artist_info"`
Albums []ArtistAlbumMetadata `json:"albums"` Albums []ArtistAlbumMetadata `json:"albums"`
} }
// TrackResponse is the response for single track requests
type TrackResponse struct { type TrackResponse struct {
Track TrackMetadata `json:"track"` Track TrackMetadata `json:"track"`
} }
// SearchResult represents search results
type SearchResult struct { type SearchResult struct {
Tracks []TrackMetadata `json:"tracks"` Tracks []TrackMetadata `json:"tracks"`
Total int `json:"total"` Total int `json:"total"`
} }
// SearchArtistResult represents an artist in search results
type SearchArtistResult struct { type SearchArtistResult struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -260,10 +236,29 @@ type SearchArtistResult struct {
Popularity int `json:"popularity"` 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 { type SearchAllResult struct {
Tracks []TrackMetadata `json:"tracks"` Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"` Artists []SearchArtistResult `json:"artists"`
Albums []SearchAlbumResult `json:"albums"`
Playlists []SearchPlaylistResult `json:"playlists"`
} }
type spotifyURI struct { type spotifyURI struct {
@@ -277,7 +272,6 @@ type accessTokenResponse struct {
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
} }
// Internal API response types
type image struct { type image struct {
URL string `json:"url"` URL string `json:"url"`
} }
@@ -303,7 +297,7 @@ type albumSimplified struct {
Images []image `json:"images"` Images []image `json:"images"`
ExternalURL externalURL `json:"external_urls"` ExternalURL externalURL `json:"external_urls"`
Artists []artist `json:"artists"` Artists []artist `json:"artists"`
AlbumType string `json:"album_type"` // album, single, compilation AlbumType string `json:"album_type"`
} }
type trackFull struct { type trackFull struct {
@@ -318,7 +312,6 @@ type trackFull struct {
Artists []artist `json:"artists"` 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) { func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
parsed, err := parseSpotifyURI(spotifyURL) parsed, err := parseSpotifyURI(spotifyURL)
if err != nil { if err != nil {
@@ -344,7 +337,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) { func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
token, err := c.getAccessToken(ctx) token, err := c.getAccessToken(ctx)
if err != nil { if err != nil {
@@ -391,12 +383,9 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
return result, nil 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) { func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
// Create cache key
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit) cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
// Check cache first
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
@@ -456,7 +445,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
}) })
} }
// Limit artists to artistLimit
artistCount := len(response.Artists.Items) artistCount := len(response.Artists.Items)
if artistCount > artistLimit { if artistCount > artistLimit {
artistCount = artistLimit artistCount = artistLimit
@@ -473,7 +461,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
}) })
} }
// Store in cache
c.cacheMu.Lock() c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{ c.searchCache[cacheKey] = &cacheEntry{
data: result, data: result,
@@ -510,7 +497,6 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
} }
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) { func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
// Check cache first
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() { if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
@@ -518,7 +504,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Track item structure for pagination
type trackItem struct { type trackItem struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -546,19 +531,25 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
} }
albumImage := firstImageURL(data.Images) albumImage := firstImageURL(data.Images)
// Get first artist ID
var firstArtistId string
if len(data.Artists) > 0 {
firstArtistId = data.Artists[0].ID
}
info := AlbumInfoMetadata{ info := AlbumInfoMetadata{
TotalTracks: data.TotalTracks, TotalTracks: data.TotalTracks,
Name: data.Name, Name: data.Name,
ReleaseDate: data.ReleaseDate, ReleaseDate: data.ReleaseDate,
Artists: joinArtists(data.Artists), Artists: joinArtists(data.Artists),
ArtistId: firstArtistId,
Images: albumImage, Images: albumImage,
} }
// Collect all tracks (including paginated)
allTrackItems := data.Tracks.Items allTrackItems := data.Tracks.Items
nextURL := data.Tracks.Next nextURL := data.Tracks.Next
// Fetch remaining tracks using pagination (no limit)
for nextURL != "" { for nextURL != "" {
var pageData struct { var pageData struct {
Items []trackItem `json:"items"` Items []trackItem `json:"items"`
@@ -580,7 +571,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
trackIDs[i] = item.ID trackIDs[i] = item.ID
} }
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token) isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems)) tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
@@ -610,7 +600,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
TrackList: tracks, TrackList: tracks,
} }
// Store in cache
c.cacheMu.Lock() c.cacheMu.Lock()
c.albumCache[albumID] = &cacheEntry{ c.albumCache[albumID] = &cacheEntry{
data: result, data: result,
@@ -621,10 +610,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
return result, nil 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 { 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) result := make(map[string]string)
var resultMu sync.Mutex var resultMu sync.Mutex
@@ -633,7 +620,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
return result return result
} }
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, maxParallelISRC) sem := make(chan struct{}, maxParallelISRC)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -642,7 +628,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
go func(id string) { go func(id string) {
defer wg.Done() defer wg.Done()
// Acquire semaphore
select { select {
case sem <- struct{}{}: case sem <- struct{}{}:
defer func() { <-sem }() defer func() { <-sem }()
@@ -663,7 +648,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
} }
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) { 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 { var data struct {
Name string `json:"name"` Name string `json:"name"`
Images []image `json:"images"` Images []image `json:"images"`
@@ -689,10 +673,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
info.Owner.Name = data.Name info.Owner.Name = data.Name
info.Owner.Images = firstImageURL(data.Images) info.Owner.Images = firstImageURL(data.Images)
// Pre-allocate with expected capacity
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total) tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
// Add first batch of tracks
for _, item := range data.Tracks.Items { for _, item := range data.Tracks.Items {
if item.Track == nil { if item.Track == nil {
continue continue
@@ -716,7 +698,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
}) })
} }
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
nextURL := data.Tracks.Next nextURL := data.Tracks.Next
for nextURL != "" { for nextURL != "" {
@@ -728,7 +709,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
} }
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil { 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) fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
break break
} }
@@ -768,7 +748,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
} }
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) { func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
// Check cache first
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() { if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
@@ -776,7 +755,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Fetch artist info
var artistData struct { var artistData struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -799,7 +777,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
Popularity: artistData.Popularity, Popularity: artistData.Popularity,
} }
// Fetch artist albums (all types: album, single, compilation)
albums := make([]ArtistAlbumMetadata, 0) albums := make([]ArtistAlbumMetadata, 0)
offset := 0 offset := 0
limit := 50 limit := 50
@@ -839,13 +816,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
}) })
} }
// Check if there are more albums
if albumsData.Next == "" || len(albumsData.Items) < limit { if albumsData.Next == "" || len(albumsData.Items) < limit {
break break
} }
offset += limit offset += limit
// Safety limit to prevent infinite loops
if offset > 500 { if offset > 500 {
break break
} }
@@ -856,7 +831,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
Albums: albums, Albums: albums,
} }
// Store in cache
c.cacheMu.Lock() c.cacheMu.Lock()
c.artistCache[artistID] = &cacheEntry{ c.artistCache[artistID] = &cacheEntry{
data: result, data: result,
@@ -927,7 +901,6 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
return err return err
} }
// Set headers (same as PC version baseHeaders)
req.Header.Set("User-Agent", c.userAgent) req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Language", "en-US,en;q=0.9") req.Header.Set("Accept-Language", "en-US,en;q=0.9")
@@ -963,8 +936,7 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
c.rngMu.Lock() c.rngMu.Lock()
defer c.rngMu.Unlock() defer c.rngMu.Unlock()
// Use Mac User-Agent format (same as PC version) macMajor := c.rng.Intn(4) + 11
macMajor := c.rng.Intn(4) + 11 // 11-14
macMinor := c.rng.Intn(5) + 4 // 4-8 macMinor := c.rng.Intn(5) + 4 // 4-8
webkitMajor := c.rng.Intn(7) + 530 // 530-536 webkitMajor := c.rng.Intn(7) + 530 // 530-536
webkitMinor := c.rng.Intn(7) + 30 // 30-36 webkitMinor := c.rng.Intn(7) + 30 // 30-36
@@ -989,7 +961,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL return spotifyURI{}, errInvalidSpotifyURL
} }
// Handle spotify: URI format
if strings.HasPrefix(trimmed, "spotify:") { if strings.HasPrefix(trimmed, "spotify:") {
parts := strings.Split(trimmed, ":") parts := strings.Split(trimmed, ":")
if len(parts) == 3 { if len(parts) == 3 {
@@ -1000,13 +971,11 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
} }
} }
// Handle URL format
parsed, err := url.Parse(trimmed) parsed, err := url.Parse(trimmed)
if err != nil { if err != nil {
return spotifyURI{}, err return spotifyURI{}, err
} }
// Handle embed.spotify.com URLs
if parsed.Host == "embed.spotify.com" { if parsed.Host == "embed.spotify.com" {
if parsed.RawQuery == "" { if parsed.RawQuery == "" {
return spotifyURI{}, errInvalidSpotifyURL return spotifyURI{}, errInvalidSpotifyURL
@@ -1019,7 +988,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return parseSpotifyURI(embedded) return parseSpotifyURI(embedded)
} }
// Handle plain ID (no scheme/host) - defaults to playlist
if parsed.Scheme == "" && parsed.Host == "" { if parsed.Scheme == "" && parsed.Host == "" {
id := strings.Trim(strings.TrimSpace(parsed.Path), "/") id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
if id == "" { if id == "" {
@@ -1045,7 +1013,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL return spotifyURI{}, errInvalidSpotifyURL
} }
// Skip intl- prefix if present
if strings.HasPrefix(parts[0], "intl-") { if strings.HasPrefix(parts[0], "intl-") {
parts = parts[1:] parts = parts[1:]
} }
@@ -1053,7 +1020,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL return spotifyURI{}, errInvalidSpotifyURL
} }
// Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id}
if len(parts) == 2 { if len(parts) == 2 {
switch parts[0] { switch parts[0] {
case "album", "track", "playlist", "artist": case "album", "track", "playlist", "artist":
@@ -1061,7 +1027,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
} }
} }
// Handle nested playlist URLs: /user/{user}/playlist/{id}
if len(parts) == 4 && parts[2] == "playlist" { if len(parts) == 4 && parts[2] == "playlist" {
return spotifyURI{Type: "playlist", ID: parts[3]}, nil return spotifyURI{Type: "playlist", ID: parts[3]}, nil
} }
+234 -283
View File
File diff suppressed because it is too large Load Diff
+132 -3
View File
@@ -120,6 +120,12 @@ import Gobackend // Import Go framework
let itemId = args["item_id"] as! String let itemId = args["item_id"] as! String
GobackendClearItemProgress(itemId) GobackendClearItemProgress(itemId)
return nil return nil
case "cancelDownload":
let args = call.arguments as! [String: Any]
let itemId = args["item_id"] as! String
GobackendCancelDownload(itemId)
return nil
case "setDownloadDirectory": case "setDownloadDirectory":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
@@ -136,6 +142,27 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response 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": case "buildFilename":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let template = args["template"] as! String let template = args["template"] as! String
@@ -155,7 +182,8 @@ import Gobackend // Import Go framework
let spotifyId = args["spotify_id"] as! String let spotifyId = args["spotify_id"] as! String
let trackName = args["track_name"] as! String let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String let artistName = args["artist_name"] as! String
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, &error) let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, durationMs, &error)
if let error = error { throw error } if let error = error { throw error }
return response return response
@@ -165,7 +193,8 @@ import Gobackend // Import Go framework
let trackName = args["track_name"] as! String let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String let artistName = args["artist_name"] as! String
let filePath = args["file_path"] as? String ?? "" let filePath = args["file_path"] as? String ?? ""
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error) let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
if let error = error { throw error } if let error = error { throw error }
return response return response
@@ -193,7 +222,8 @@ import Gobackend // Import Go framework
let query = args["query"] as! String let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15 let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3 let artistLimit = args["artist_limit"] as? Int ?? 3
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), &error) let filter = args["filter"] as? String ?? ""
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error } if let error = error { throw error }
return response return response
@@ -219,6 +249,13 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response 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": case "convertSpotifyToDeezer":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let resourceType = args["resource_type"] as! String let resourceType = args["resource_type"] as! String
@@ -234,6 +271,43 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response 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": case "preWarmTrackCache":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let tracksJson = args["tracks"] as! String let tracksJson = args["tracks"] as! String
@@ -367,6 +441,14 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return nil 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": case "searchTracksWithExtensions":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let query = args["query"] as! String let query = args["query"] as! String
@@ -381,6 +463,14 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response 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": case "removeExtension":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String let extensionId = args["extension_id"] as! String
@@ -503,6 +593,30 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "getAlbumWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let albumId = args["album_id"] as! String
let response = GobackendGetAlbumWithExtensionJSON(extensionId, albumId, &error)
if let error = error { throw error }
return response
case "getPlaylistWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let playlistId = args["playlist_id"] as! String
let response = GobackendGetPlaylistWithExtensionJSON(extensionId, playlistId, &error)
if let error = error { throw error }
return response
case "getArtistWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let artistId = args["artist_id"] as! String
let response = GobackendGetArtistWithExtensionJSON(extensionId, artistId, &error)
if let error = error { throw error }
return response
// Extension Post-Processing API // Extension Post-Processing API
case "runPostProcessing": case "runPostProcessing":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
@@ -558,6 +672,21 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return nil 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: default:
throw NSError( throw NSError(
domain: "SpotiFLAC", domain: "SpotiFLAC",
+17
View File
@@ -4,6 +4,23 @@
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>de</string>
<string>es</string>
<string>fr</string>
<string>hi</string>
<string>id</string>
<string>ja</string>
<string>ko</string>
<string>nl</string>
<string>pt</string>
<string>ru</string>
<string>zh</string>
<string>zh-Hans</string>
<string>zh-Hant</string>
</array>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>SpotiFLAC</string> <string>SpotiFLAC</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
+6
View File
@@ -0,0 +1,6 @@
arb-dir: lib/l10n/arb
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
output-dir: lib/l10n
nullable-getter: false
+21 -1
View File
@@ -1,13 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:spotiflac_android/screens/main_shell.dart'; import 'package:spotiflac_android/screens/main_shell.dart';
import 'package:spotiflac_android/screens/setup_screen.dart'; import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart'; import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
final _routerProvider = Provider<GoRouter>((ref) { final _routerProvider = Provider<GoRouter>((ref) {
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch)); final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
return GoRouter( return GoRouter(
@@ -31,6 +32,17 @@ class SpotiFLACApp extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(_routerProvider); final router = ref.watch(_routerProvider);
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
Locale? locale;
if (localeString != 'system') {
if (localeString.contains('_')) {
final parts = localeString.split('_');
locale = Locale(parts[0], parts[1]);
} else {
locale = Locale(localeString);
}
}
return DynamicColorWrapper( return DynamicColorWrapper(
builder: (lightTheme, darkTheme, themeMode) { builder: (lightTheme, darkTheme, themeMode) {
@@ -43,6 +55,14 @@ class SpotiFLACApp extends ConsumerWidget {
themeAnimationDuration: const Duration(milliseconds: 300), themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut, themeAnimationCurve: Curves.easeInOut,
routerConfig: router, routerConfig: router,
locale: locale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
); );
}, },
); );
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.0.0'; static const String version = '3.3.5';
static const String buildNumber = '57'; static const String buildNumber = '70';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
export 'package:spotiflac_android/l10n/app_localizations.dart';
/// Extension to easily access AppLocalizations from BuildContext
extension AppLocalizationsX on BuildContext {
/// Get the AppLocalizations instance
/// Usage: context.l10n.navHome
AppLocalizations get l10n => AppLocalizations.of(this);
}
+34
View File
@@ -0,0 +1,34 @@
// GENERATED FILE - DO NOT EDIT
// 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 70
import 'package:flutter/widgets.dart';
/// Minimum translation completion threshold used to filter languages.
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('tr'),
];
/// Set of locale codes for quick lookup.
const Set<String> filteredLocaleCodes = <String>{
'en',
'ru',
'es_ES',
'id',
'pt_PT',
'ja',
'tr',
};
+8 -8
View File
@@ -7,15 +7,18 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Initialize notification service await CoverCacheManager.initialize();
await NotificationService().initialize(); debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
// Initialize share intent service await Future.wait([
await ShareIntentService().initialize(); NotificationService().initialize(),
ShareIntentService().initialize(),
]);
runApp( runApp(
ProviderScope( ProviderScope(
@@ -40,6 +43,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
void initState() { void initState() {
super.initState(); super.initState();
_initializeExtensions(); _initializeExtensions();
ref.read(downloadHistoryProvider);
} }
Future<void> _initializeExtensions() async { Future<void> _initializeExtensions() async {
@@ -48,11 +52,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
final extensionsDir = '${appDir.path}/extensions'; final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data'; final dataDir = '${appDir.path}/extension_data';
// Create directories if needed
await Directory(extensionsDir).create(recursive: true); await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true); await Directory(dataDir).create(recursive: true);
// Initialize extension system
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir); await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
} catch (e) { } catch (e) {
debugPrint('Failed to initialize extensions: $e'); debugPrint('Failed to initialize extensions: $e');
@@ -61,8 +63,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Eagerly initialize download history provider to load from storage
ref.watch(downloadHistoryProvider);
return widget.child; return widget.child;
} }
} }
+6 -9
View File
@@ -3,23 +3,21 @@ import 'package:spotiflac_android/models/track.dart';
part 'download_item.g.dart'; part 'download_item.g.dart';
/// Download status enum
enum DownloadStatus { enum DownloadStatus {
queued, queued,
downloading, downloading,
finalizing, // Embedding metadata, cover, lyrics finalizing,
completed, completed,
failed, failed,
skipped, skipped,
} }
/// Error type enum for better error handling
enum DownloadErrorType { enum DownloadErrorType {
unknown, unknown,
notFound, // Track not found on any service notFound,
rateLimit, // Rate limited by service rateLimit,
network, // Network/connection error network,
permission, // File/folder permission error permission,
} }
@JsonSerializable() @JsonSerializable()
@@ -29,7 +27,7 @@ class DownloadItem {
final String service; final String service;
final DownloadStatus status; final DownloadStatus status;
final double progress; final double progress;
final double speedMBps; // Download speed in MB/s final double speedMBps;
final String? filePath; final String? filePath;
final String? error; final String? error;
final DownloadErrorType? errorType; final DownloadErrorType? errorType;
@@ -78,7 +76,6 @@ class DownloadItem {
); );
} }
/// Get user-friendly error message based on error type
String get errorMessage { String get errorMessage {
if (error == null) return ''; if (error == null) return '';
+57 -37
View File
@@ -12,24 +12,29 @@ class AppSettings {
final bool embedLyrics; final bool embedLyrics;
final bool maxQualityCover; final bool maxQualityCover;
final bool isFirstLaunch; final bool isFirstLaunch;
final int concurrentDownloads; // 1 = sequential (default), max 3 final int concurrentDownloads;
final bool checkForUpdates; // Check for updates on app start final bool checkForUpdates;
final String updateChannel; // stable, preview final String updateChannel;
final bool hasSearchedBefore; // Hide helper text after first search final bool hasSearchedBefore;
final String folderOrganization; // none, artist, album, artist_album final String folderOrganization;
final String historyViewMode; // list, grid final String historyViewMode;
final String historyFilterMode; // all, albums, singles final String historyFilterMode;
final bool askQualityBeforeDownload; // Show quality picker before each download final bool askQualityBeforeDownload;
final String spotifyClientId; // Custom Spotify client ID (empty = use default) final String spotifyClientId;
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default) final String spotifyClientSecret;
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set) final bool useCustomSpotifyCredentials;
final String metadataSource; // spotify, deezer - source for search and metadata final String metadataSource;
final bool enableLogging; // Enable detailed logging for debugging final bool enableLogging;
final bool useExtensionProviders; // Use extension providers for downloads when available final bool useExtensionProviders;
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID final String? searchProvider;
final bool separateSingles; // Separate singles/EPs into their own folder final bool separateSingles;
final String albumFolderStructure; // artist_album or album_only final String albumFolderStructure;
final bool showExtensionStore; // Show Extension Store tab in navigation final bool showExtensionStore;
final String locale;
final String lyricsMode;
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128'
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
const AppSettings({ const AppSettings({
this.defaultService = 'tidal', this.defaultService = 'tidal',
@@ -40,24 +45,29 @@ class AppSettings {
this.embedLyrics = true, this.embedLyrics = true,
this.maxQualityCover = true, this.maxQualityCover = true,
this.isFirstLaunch = true, this.isFirstLaunch = true,
this.concurrentDownloads = 1, // Default: sequential (off) this.concurrentDownloads = 1,
this.checkForUpdates = true, // Default: enabled this.checkForUpdates = true,
this.updateChannel = 'stable', // Default: stable releases only this.updateChannel = 'stable',
this.hasSearchedBefore = false, // Default: show helper text this.hasSearchedBefore = false,
this.folderOrganization = 'none', // Default: no folder organization this.folderOrganization = 'none',
this.historyViewMode = 'grid', // Default: grid view this.historyViewMode = 'grid',
this.historyFilterMode = 'all', // Default: show all this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true, // Default: ask quality before download this.askQualityBeforeDownload = true,
this.spotifyClientId = '', // Default: use built-in credentials this.spotifyClientId = '',
this.spotifyClientSecret = '', // Default: use built-in credentials this.spotifyClientSecret = '',
this.useCustomSpotifyCredentials = true, // Default: use custom if set this.useCustomSpotifyCredentials = true,
this.metadataSource = 'deezer', // Default: Deezer (no rate limit) this.metadataSource = 'deezer',
this.enableLogging = false, // Default: disabled for performance this.enableLogging = false,
this.useExtensionProviders = true, // Default: use extensions when available this.useExtensionProviders = true,
this.searchProvider, // Default: null (use Deezer/Spotify) this.searchProvider,
this.separateSingles = false, // Default: disabled this.separateSingles = false,
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true, // Default: show store this.showExtensionStore = true,
this.locale = 'system',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.useAllFilesAccess = false,
this.autoExportFailedDownloads = false,
}); });
AppSettings copyWith({ AppSettings copyWith({
@@ -84,10 +94,15 @@ class AppSettings {
bool? enableLogging, bool? enableLogging,
bool? useExtensionProviders, bool? useExtensionProviders,
String? searchProvider, String? searchProvider,
bool clearSearchProvider = false, // Set to true to clear searchProvider to null bool clearSearchProvider = false,
bool? separateSingles, bool? separateSingles,
String? albumFolderStructure, String? albumFolderStructure,
bool? showExtensionStore, bool? showExtensionStore,
String? locale,
String? lyricsMode,
String? tidalHighFormat,
bool? useAllFilesAccess,
bool? autoExportFailedDownloads,
}) { }) {
return AppSettings( return AppSettings(
defaultService: defaultService ?? this.defaultService, defaultService: defaultService ?? this.defaultService,
@@ -116,6 +131,11 @@ class AppSettings {
separateSingles: separateSingles ?? this.separateSingles, separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore, showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
); );
} }
+13 -1
View File
@@ -32,8 +32,15 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?, searchProvider: json['searchProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false, separateSingles: json['separateSingles'] as bool? ?? false,
albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album', albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true, showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
autoExportFailedDownloads:
json['autoExportFailedDownloads'] as bool? ?? false,
); );
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) => Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -64,4 +71,9 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'separateSingles': instance.separateSingles, 'separateSingles': instance.separateSingles,
'albumFolderStructure': instance.albumFolderStructure, 'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore, 'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'useAllFilesAccess': instance.useAllFilesAccess,
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
}; };
-6
View File
@@ -9,7 +9,6 @@ const String kUseAmoledKey = 'use_amoled';
/// Default Spotify green color for fallback /// Default Spotify green color for fallback
const int kDefaultSeedColor = 0xFF1DB954; const int kDefaultSeedColor = 0xFF1DB954;
/// Theme settings model for Material Expressive 3
class ThemeSettings { class ThemeSettings {
final ThemeMode themeMode; final ThemeMode themeMode;
final bool useDynamicColor; final bool useDynamicColor;
@@ -23,10 +22,8 @@ class ThemeSettings {
this.useAmoled = false, this.useAmoled = false,
}); });
/// Get seed color as Color object
Color get seedColor => Color(seedColorValue); Color get seedColor => Color(seedColorValue);
/// Create a copy with updated values
ThemeSettings copyWith({ ThemeSettings copyWith({
ThemeMode? themeMode, ThemeMode? themeMode,
bool? useDynamicColor, bool? useDynamicColor,
@@ -41,7 +38,6 @@ class ThemeSettings {
); );
} }
/// Convert to JSON map for persistence
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
kThemeModeKey: themeMode.name, kThemeModeKey: themeMode.name,
kUseDynamicColorKey: useDynamicColor, kUseDynamicColorKey: useDynamicColor,
@@ -49,7 +45,6 @@ class ThemeSettings {
kUseAmoledKey: useAmoled, kUseAmoledKey: useAmoled,
}; };
/// Create from JSON map
factory ThemeSettings.fromJson(Map<String, dynamic> json) { factory ThemeSettings.fromJson(Map<String, dynamic> json) {
return ThemeSettings( return ThemeSettings(
themeMode: _themeModeFromString(json[kThemeModeKey] as String?), themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
@@ -74,7 +69,6 @@ class ThemeSettings {
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode; themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
} }
/// Helper to convert string to ThemeMode
ThemeMode _themeModeFromString(String? value) { ThemeMode _themeModeFromString(String? value) {
if (value == null) return ThemeMode.system; if (value == null) return ThemeMode.system;
return ThemeMode.values.firstWhere( return ThemeMode.values.firstWhere(
+12 -5
View File
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
part 'track.g.dart'; part 'track.g.dart';
/// Track model representing a music track
@JsonSerializable() @JsonSerializable()
class Track { class Track {
final String id; final String id;
@@ -18,8 +17,9 @@ class Track {
final String? releaseDate; final String? releaseDate;
final String? deezerId; final String? deezerId;
final ServiceAvailability? availability; final ServiceAvailability? availability;
final String? source; // Extension ID that provided this track (null for built-in sources) final String? source;
final String? albumType; // album, single, ep, compilation (from metadata API) final String? albumType;
final String? itemType;
const Track({ const Track({
required this.id, required this.id,
@@ -37,15 +37,22 @@ class Track {
this.availability, this.availability,
this.source, this.source,
this.albumType, this.albumType,
this.itemType,
}); });
/// Check if this track is a single (based on album_type metadata)
bool get isSingle => albumType == 'single' || albumType == 'ep'; bool get isSingle => albumType == 'single' || albumType == 'ep';
bool get isAlbumItem => itemType == 'album';
bool get isPlaylistItem => itemType == 'playlist';
bool get isArtistItem => itemType == 'artist';
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json); factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this); Map<String, dynamic> toJson() => _$TrackToJson(this);
/// Check if this track is from an extension
bool get isFromExtension => source != null && source!.isNotEmpty; bool get isFromExtension => source != null && source!.isNotEmpty;
} }
+2
View File
@@ -26,6 +26,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
), ),
source: json['source'] as String?, source: json['source'] as String?,
albumType: json['albumType'] as String?, albumType: json['albumType'] as String?,
itemType: json['itemType'] as String?,
); );
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
@@ -44,6 +45,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'availability': instance.availability, 'availability': instance.availability,
'source': instance.source, 'source': instance.source,
'albumType': instance.albumType, 'albumType': instance.albumType,
'itemType': instance.itemType,
}; };
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) => ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>

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