Compare commits

...

140 Commits

Author SHA1 Message Date
zarzet e4a6177cb5 feat: add metadata screen support for local library items
- TrackMetadataScreen now accepts both DownloadHistoryItem and LocalLibraryItem
- Tapping local library tracks in Library tab opens metadata screen
- Shows extracted metadata from audio files (artist, album, track number, etc)
- Supports local cover art display from extracted covers
2026-02-04 12:42:16 +07:00
zarzet 34ffbca3e8 fix: improve share intent handling for YouTube Music links
- Check both path and message fields for shared URLs
- Wait for extensions to initialize before handling shared URLs
- Add retry logic (3 attempts) for extension URL handlers with empty data
- Show error message if metadata fails to load after retries
2026-02-04 12:18:53 +07:00
zarzet f8acd8f3b6 fix: show search filter bar only after results load 2026-02-04 11:48:38 +07:00
zarzet 9956f051ac feat: disable Amazon in service picker (fallback only) 2026-02-04 11:26:29 +07:00
zarzet b33ae905a2 feat: add support for Deezer, Tidal, and YT Music links 2026-02-04 11:18:52 +07:00
zarzet 11eb0aa12a docs: shorten changelog format for v3.3.6 and v3.4.0 2026-02-04 10:57:34 +07:00
zarzet 7c08321ce3 refactor: continue code cleanup 2026-02-04 10:42:51 +07:00
zarzet e20becdca7 refactor: remove more redundant comments 2026-02-04 10:20:04 +07:00
zarzet 24897e25e2 refactor: clean up redundant comments and code 2026-02-04 10:05:32 +07:00
zarzet 2dc4cef583 feat: add device info to log export
- Add exportWithDeviceInfo() method to LogBuffer
- Include app version, build number in export
- Include device info: manufacturer, model, OS version, SDK level
- Include log summary with counts by level
- Update copy/share logs to use new method with device info
2026-02-04 09:53:40 +07:00
zarzet 34c95fbd81 fix: persist lastScannedAt to SharedPreferences
- lastScannedAt was only stored in memory state, lost on app restart
- Now saves to SharedPreferences after scan completes
- Loads lastScannedAt when loading library from database
- Clears lastScannedAt when clearing library
2026-02-04 09:49:22 +07:00
zarzet 9071db9b88 feat: block duplicate downloads for tracks in local library
- Add local library check before downloading in all screens
- Show 'Already exists in your library' snackbar when track is found
- Add 'In Library' badge to track items in album and playlist screens
- Update home_tab, album_screen, playlist_screen, artist_screen
- Add snackbarAlreadyInLibrary localization string
2026-02-04 09:39:54 +07:00
zarzet 3eb2fdd7fa fix: resolve analyzer warnings
- Fix empty catch block in track_provider.dart with comment
- Replace deprecated withOpacity() with withValues(alpha:) in library_settings_page.dart
2026-02-03 23:15:32 +07:00
zarzet 99e0d3d361 refactor: remove cloud save feature entirely
This feature was deemed unnecessary and was adding complexity to the app.

Removed:
- lib/services/cloud_upload_service.dart
- lib/providers/upload_queue_provider.dart
- lib/screens/settings/cloud_settings_page.dart
- lib/screens/settings/widgets/ (cloud status hero card)
- All cloud* settings from AppSettings model
- All cloud-related localization keys (~70 keys)
- Cloud settings from settings_provider.dart
- Cloud menu item from settings_tab.dart
- Cloud upload trigger from download_queue_provider.dart

Dependencies removed:
- webdav_client: ^1.2.2
- dartssh2: ^2.13.0

CHANGELOG updated to remove all cloud save references.
2026-02-03 23:10:19 +07:00
zarzet a2eb89e230 feat: add advanced library filters (source, quality, format, date)
- Add filter button next to Select in Library tab (All/Singles)
- Source filter: All, Downloaded, Local
- Quality filter: Hi-Res (24bit), CD (16bit), Lossy
- Format filter: Dynamic based on available formats (FLAC, MP3, etc.)
- Date filter: Today, This Week, This Month, This Year
- Badge shows active filter count
- Long-press filter button to reset all filters
- Filters apply to both All and Singles tabs
- Add 18 new localization keys for filter UI
2026-02-03 22:52:29 +07:00
zarzet b21e953ef1 perf: optimize LocalAlbumScreen with track caching
- Cache sorted tracks, disc groups, and disc numbers on init
- Rebuild caches only when tracks change (didUpdateWidget)
- Use cached dominant color from PaletteService if available
- Defer color extraction to post-frame callback
- Eliminates redundant sorting and grouping on every build
2026-02-03 22:25:49 +07:00
zarzet 0ef086ce57 fix: improve queue_tab code quality and consistency
- Fix redundant ternary operator for placeholder icon (Icons.music_note)
- Normalize UnifiedLibraryItem.albumKey to lowercase for consistency
- Fix empty state condition to include local albums check
- Add caching for unified items and local library filtering
- Optimize file existence check with scheduled updates
- Refactor filtering logic for better performance
2026-02-03 22:25:11 +07:00
zarzet 72d45746a5 feat: add local library albums to Albums tab with unified grid and LocalAlbumScreen
- Add local library albums as clickable cards in Albums filter
- Merge downloaded and local albums into single unified grid (fix layout gaps)
- Create LocalAlbumScreen for viewing local album details with:
  - Cover art display with dominant color extraction
  - Album info card with Local badge and quality info
  - Track list with disc grouping support
  - Selection mode with delete functionality
  - UI consistent with DownloadedAlbumScreen (Card + ListTile layout)
- Add singles filter support for local library singles
- Add extractDominantColorFromFile to PaletteService
- Add delete(id) method to LibraryDatabase
- Add removeItem(id) method to LocalLibraryNotifier
- Update CHANGELOG.md for v3.4.0
2026-02-03 21:51:40 +07:00
zarzet 9c22f41a3e feat: rename History tab to Library and show local library items
- Rename bottom navigation 'History' to 'Library'
- Add Local Library section showing scanned tracks below downloaded tracks
- Add source badge to each item (Downloaded/Local) for clear identification
- Add new localization strings for Library tab and source badges
- Local library items can be played directly from the library tab
2026-02-03 19:53:53 +07:00
zarzet 22f001a735 feat: add SFTP host key management and security improvements
- Add HTTPS URL validation for extension store registry and downloads
- Add Reset SFTP Host Key button (per-server)
- Add Reset All SFTP Host Keys button
- Add SFTP host key verification with TOFU (Trust On First Use)
- Update cloud upload service with host key storage
- Add flutter_secure_storage dependency for secure password storage
2026-02-03 19:25:09 +07:00
zarzet 26d464d3c7 feat: add local library scanning with duplicate detection
- Add Go backend library scanner for FLAC, M4A, MP3, Opus, OGG files
- Read metadata from file tags (ISRC, track name, artist, album, bit depth, sample rate)
- Fallback to filename parsing when tags unavailable
- Add SQLite database for O(1) duplicate lookups
- Show 'In Library' badge on search results for existing tracks
- Match by ISRC (exact) or track name + artist (fuzzy)
- Add Library Settings page with scan, cleanup, and clear actions
- Add 30+ localization strings for library feature
2026-02-03 19:24:28 +07:00
zarzet 3d6a3f8d04 fix: improve failed downloads export organization
- Create 'failed_downloads' subfolder to keep exports separate from music
- Use daily files (YYYY-MM-DD) instead of timestamp per export
- Append to existing file if same day, create new file on new day
- Add time prefix to each entry for tracking within the day
- Keeps failed downloads organized and prevents file fragmentation
2026-02-03 15:26:25 +07:00
zarzet 39ce22a9e2 refactor(ui): move Download Network and Auto Export settings higher
- Relocate Download Network and Auto Export Failed settings
- Now appears after File Settings, before Storage Access section
- Grouped together under 'Download' section header
- More visible and easier to access
2026-02-03 15:22:43 +07:00
zarzet 88f9a65d11 fix(l10n): fix ICU plural syntax warnings in Russian and Turkish
- Remove redundant =1 plural forms that override 'one' category
- Russian: fix 10 plural strings (tracks, albums, releases, delete messages)
- Turkish: fix 5 plural strings (tracks, albums, delete messages)
2026-02-03 15:17:22 +07:00
zarzet 663ee12bcc feat: implement cloud upload with WebDAV and SFTP support
- Add CloudUploadService with WebDAV and SFTP upload methods
- Add UploadQueueProvider for managing upload queue
- Integrate upload trigger after download completes
- Update CloudSettingsPage with actual connection test and queue UI
- Add webdav_client ^1.2.2 and dartssh2 ^2.13.0 dependencies
- Remove Google Drive option (not implemented)
- Bump version to 3.4.0+72
2026-02-03 15:14:29 +07:00
zarzet 8c201b5b4a feat: add Cloud Save settings page (UI only)
- Add cloud upload settings to settings model (provider, URL, credentials, path)
- Create CloudSettingsPage with WebDAV and SFTP provider options
- Add Cloud Save menu item in main settings
- Add localization strings for cloud settings
- Actual upload implementation will come in v3.4.0

Settings fields added:
- cloudUploadEnabled: toggle auto-upload
- cloudProvider: 'webdav', 'sftp', or 'none'
- cloudServerUrl: server URL
- cloudUsername/cloudPassword: credentials
- cloudRemotePath: destination folder path
2026-02-02 07:20:15 +07:00
zarzet 5e19178bc0 feat: WiFi-only download mode
- Add network mode setting (any/wifi_only) in settings model
- Add connectivity check in download queue processor
- Downloads pause automatically when on mobile data (if wifi_only enabled)
- Add UI toggle in Download Settings page
- Add localization strings for network mode setting
- Bump version to 3.3.6+71
2026-02-02 07:13:03 +07:00
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 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 fc9a2ddc2a New translations app_en.arb (German) 2026-01-25 12:23:03 +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
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 ccb8f98df5 fix: use --data-urlencode for Telegram message to handle special chars (+, &) 2026-01-22 04:26:02 +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
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
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
96 changed files with 12004 additions and 2581 deletions
+2 -2
View File
@@ -71,7 +71,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.21"
go-version: "1.25"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
@@ -174,7 +174,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.21"
go-version: "1.25"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
+75 -35
View File
@@ -1,6 +1,75 @@
# Changelog
## [3.3.0] - 2026-01-31
## [3.4.0] - 2026-02-03
### Highlights
- **Local Library Scanning** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
- **Duplicate Detection** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
- **Unified Library Tab**: History renamed to Library, shows Downloaded + Local Library tracks with source badges
### Added
- Local Album Screen with cover art, disc grouping, and selection mode
- Albums tab shows local library albums with folder icon badge
- Singles filter includes local library singles
- Advanced library filters: Source, Quality, Format, Date
- Cover art extraction from embedded tags (FLAC, MP3, Opus/Ogg)
- "Already in Library" notification when downloading existing tracks
- Spotify secrets now stored in secure storage (`flutter_secure_storage`)
- **Multi-Service Link Support**: Share links from Deezer, Tidal, and YouTube Music (in addition to Spotify)
- Deezer: Full support for track, album, playlist, artist links
- Tidal: Track links converted via SongLink to Spotify/Deezer for metadata
- YouTube Music: Handled via ytmusic extension URL handler
- Local library tracks now open metadata screen on tap
### Changed
- Extension HTTP sandbox enforces HTTPS and blocks private IPs
- Extension file sandbox validates paths with boundary-safe checks
### Fixed
- Search filter bar now only appears after results load, not during loading
- MP3/Ogg metadata parsing (ID3v2 extended headers, Ogg packet reassembly)
- Library scan metadata (ISRC, disc number, release date)
- Cover cache robustness (size + mtime cache key)
- Local library selection and delete in list/grid views
- Albums/Singles count includes local library items
---
## [3.3.6] - 2026-02-02
### Added
- **WiFi-Only Download Mode**: Pause downloads on mobile data, auto-resume on WiFi (Settings > Download > Download Network)
- Added `connectivity_plus: ^6.0.3` dependency
---
## [3.3.5] - 2026-02-01
Same as 3.3.1 but fixes crash issues caused by FFmpeg.
### Added
- **Export Failed Downloads**: Export failed downloads to TXT file for easy lookup on other platforms
- **Auto-Export Setting**: Option to automatically export failed downloads when queue finishes
### Fixed
- **FFmpeg Crash**: Fixed crash issues during M4A to MP3/Opus conversion
- **Service Selection Ignored**: Fixed bug where selecting Qobuz/Amazon from service picker was ignored and always used Tidal instead
- **iOS iCloud Drive Permission Error**: Block iCloud Drive folder selection on iOS (Go backend cannot access iCloud due to sandboxing)
### Changed
- **Amazon Fallback Only**: Amazon Music is now grayed out in service picker and can only be used as fallback provider
---
## [3.3.1] - 2026-02-01
### Added
@@ -15,12 +84,13 @@
### Changed
- **Amazon Download API**: Switched to AfkarXYZ API ([#108](https://github.com/zarzet/SpotiFLAC-Mobile/issues/108))
- **Qobuz Download API**: Added Jumo API as fallback ([#108](https://github.com/zarzet/SpotiFLAC-Mobile/issues/108))
- **Amazon Download API**: Switched to AfkarXYZ API
- **Qobuz Download API**: Added Jumo API as fallback
- **Search Results**: Reduced artist limit from 5 to 2
### Fixed
- **MP3 Download Error 403**: Fixed 403 Forbidden error when downloading MP3 files ([#108](https://github.com/zarzet/SpotiFLAC-Mobile/issues/108))
- **Opus Cover Art**: Implemented METADATA_BLOCK_PICTURE for proper cover embedding
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/zarzet/SpotiFLAC-Mobile/issues/112))
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/zarzet/SpotiFLAC-Mobile/issues/110))
@@ -109,31 +179,26 @@
- Perfect for players like Samsung Music that prefer external .lrc files
- LRC files include metadata headers (title, artist, by:SpotiFLAC-Mobile)
- Works with all download services (Tidal, Qobuz, Amazon)
- **CSV Import Quality Selection**: Choose audio quality when importing CSV playlists
- Quality picker now appears before adding CSV tracks to download queue
- Select between FLAC qualities (Lossless, Hi-Res, Hi-Res Max) or MP3
- Respects "Ask quality before download" setting - uses default quality if disabled
- **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory
- Cover images no longer disappear when app is closed or device restarts
- Cache stored in `app_flutter/cover_cache/` directory (not cleared by system)
- Maximum 1000 images cached for up to 365 days
- Covers are cached when displayed in History, Home, Album, Artist, or any other screen
- New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management
- **Extended Metadata from Deezer Enrichment**: Track downloads now include label, copyright, and genre metadata from Deezer
- New fields in `ExtTrackMetadata`: `label`, `copyright`, `genre`
- Metadata fetched during `enrichTrack()` via Deezer album API
- Embedded as FLAC Vorbis comments: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT`
- Works for both extension downloads and built-in provider downloads (Tidal, Qobuz, Amazon)
- **Track Metadata Screen Extended Info**: Genre, label, and copyright now displayed in track metadata screen
- Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model
- Metadata is stored in download history and persists across app restarts
- New localization strings: `trackGenre`, `trackLabel`, `trackCopyright`
- **`utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings
- `**utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings
- Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0`
- Useful for extensions that need to rotate User-Agents to avoid detection
@@ -142,7 +207,6 @@
- **Portuguese Language Bug**: Fixed locale parsing for languages with country codes (e.g., pt_PT, es_ES)
- App now correctly loads Portuguese and Spanish translations
- Updated Portuguese label to "Português (Brasil)"
- **VM Race Condition Panic**: Fixed `panic during execution: runtime error: index out of range [-2]` crash when switching search providers
- Root cause: Goja VM was being accessed concurrently by multiple goroutines without synchronization
- Added `VMMu sync.Mutex` to `LoadedExtension` struct
@@ -151,16 +215,13 @@
- `EnrichTrack`, `CheckAvailability`, `GetDownloadURL`, `Download`
- `CustomSearch`, `HandleURL`, `MatchTrack`, `PostProcess`
- Prevents race conditions when rapidly switching between extension search providers
- **Tidal Release Date Fallback**: Fixed missing release date in FLAC metadata when downloading from Tidal
- Now uses Tidal API's release date when `req.ReleaseDate` is empty
- Ensures release date is always embedded in downloaded files
- **Extended Metadata for M4A→FLAC Conversion**: Fixed genre, label, and copyright not being embedded when converting Amazon M4A to FLAC
- Flutter now extracts extended metadata from Go backend response
- Passes `genre`, `label`, `copyright` parameters to `_embedMetadataAndCover()`
- Tags correctly embedded during FFmpeg conversion
- **Extended Metadata for MP3 Conversion**: Genre, label, and copyright now embedded in MP3 files when converting from FLAC
- Added `genre`, `label`, `copyright` parameters to `_embedMetadataToMp3()`
- Tags embedded as ID3v2: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT`
@@ -221,7 +282,6 @@
- `go_backend/httputil.go`: Updated `getRandomUserAgent()` to use modern Chrome versions
- `go_backend/tidal.go`: Added release date fallback logic
- `go_backend/exports.go`: Added `Genre`, `Label`, `Copyright` fields to `DownloadResponse`
- **Flutter Changes**:
- `lib/services/cover_cache_manager.dart`: New persistent cache manager for cover images (365 days, 1000 images max)
- `lib/widgets/cached_cover_image.dart`: Wrapper widget for CachedNetworkImage with persistent cache
@@ -246,7 +306,6 @@
- Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot))
- **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
- Tap the search icon to reveal a dropdown menu with all available search providers
- Shows default provider (Deezer based on metadata source setting) at the top
@@ -256,56 +315,46 @@
- Search hint text updates immediately when switching providers
- Re-triggers search automatically if there's existing text in the search bar
- Eliminates need to navigate to Settings > Extensions > Search Provider
- **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
- Extensions can define `button` type in manifest settings
- Triggers JavaScript function when tapped (e.g., start OAuth flow)
- Useful for authentication, manual sync, or any custom action
- **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
- Fetches genre and label from Deezer album API for each track
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
- Works automatically when Deezer track ID is available (via ISRC matching)
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads
- **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
- Available in both the quality picker dialog and default quality settings
- Works with all services (Tidal, Qobuz, Amazon) and extensions
- **MP3 Metadata Embedding**: Full metadata support for MP3 files
- Cover art embedded using ID3v2 tags
- Synced lyrics embedded (fetched from lrclib.net)
- All metadata preserved: title, artist, album, album artist, track/disc number, date, ISRC
- Automatic tag conversion from Vorbis comments (FLAC) to ID3v2 (MP3)
- **Dominant Color Header**: Album, Playlist, Downloaded Album, and Track Metadata screens now feature dynamic header backgrounds
- Extracts dominant color from cover art using `palette_generator`
- Creates a gradient from dominant color to theme surface color
- Smooth 500ms color transition animation
- **Larger Cover Art**: Cover images on detail screens are now 50% of screen width (previously 140px fixed)
- More prominent album artwork display
- Larger shadow and rounded corners (20px radius)
- Higher resolution cover caching
- **Sticky Title**: Title appears in AppBar when scrolling past the info card
- Smooth fade-in animation (200ms) when scrolling down
- Title hidden when header is expanded (shows in info card instead)
- AppBar uses theme color (surface) for clean, native look
- Works on Album, Playlist, Downloaded Album, Track Metadata, and Artist screens
- **Artist Name in Album Screen**: Album info card now displays artist name below album title
- Extracted from first track's artist metadata
- Styled with `onSurfaceVariant` color for visual hierarchy
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
- Visual disc separator header showing "Disc 1", "Disc 2", etc.
- Tracks sorted by disc number first, then by track number
- Single-disc albums display normally without separators
- Fixes confusion when albums have duplicate track numbers across discs
- **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
- Prevents flooding the recents list when downloading full albums
- Groups tracks by album name and artist
@@ -318,7 +367,6 @@
- MP3 files now saved in the same folder as FLAC (no separate MP3 subfolder)
- Original FLAC file automatically deleted after successful conversion
- New `embedMetadataToMp3()` method for MP3-specific tag embedding
- **Sticky Header Theme Integration**: AppBar background uses `colorScheme.surface` instead of dominant color when collapsed
- Dark theme: Black background with white text
- Light theme: White background with black text
@@ -330,12 +378,10 @@
- MP3 files now show "320kbps" instead of FLAC's bit depth/sample rate
- History no longer stores FLAC audio specs for converted MP3 files
- Both File Info badges and metadata grid show correct MP3 quality
- **Empty Catch Blocks**: Fixed analyzer warnings for empty catch blocks
- `download_queue_provider.dart`: Added comments explaining why polling errors are silently ignored
- `track_provider.dart`: Added comments explaining why availability check errors are silently ignored
- `ffmpeg_service.dart`: Added proper error logging for temp file cleanup failures
- **Russian Plural Forms**: Fixed ICU syntax warnings in Russian localization
- Removed redundant `=1` clauses that were overriding `one` plural category
- Affected 10 plural strings including track counts and delete confirmations
@@ -355,24 +401,20 @@
- Thread-safe cache with automatic expiration
- Cache key based on artist, track, and duration
- Log indicator shows "(cached)" when lyrics are served from cache
- **Lyrics Duration Matching**: Improved lyrics accuracy with duration-based matching
- Compares track duration with lrclib.net results
- 10-second tolerance to handle version differences (radio edit, remaster, etc.)
- Prioritizes synced lyrics over plain text when duration matches
- Falls back gracefully if no duration match found
- **Deezer Cover Art Upgrade**: Cover art from Deezer CDN now automatically upgraded to maximum quality
- Detects Deezer CDN URLs (`cdn-images.dzcdn.net`)
- Upgrades cover resolution to 1800x1800 (max available)
- Works alongside existing cover upgrade
- **Live Search for Extensions**: Search-as-you-type functionality for extension search
- 800ms debounce delay to prevent excessive API calls
- Minimum 3 characters required before searching
- Concurrency control to prevent race conditions in extension runtime
- Queues pending searches if a search is already in progress
- **Russian Language Support**: Added Russian (Русский) translation - 99% complete
- Translated via Crowdin community contributions
- Covers all UI elements, settings, and error messages
@@ -383,12 +425,10 @@
- Added per-directory build lock using `sync.Map` and `sync.Mutex`
- Double-check locking pattern ensures index is built only once
- Significantly improves performance during CSV import with many tracks
- **Queue Tab Scroll Exception**: Fixed Flutter rendering exception with NestedScrollView
- Disabled Material 3 stretch overscroll indicator that caused `_StretchController` assertion
- Wrapped NestedScrollView with ScrollConfiguration to prevent `setState during build` errors
- Issue was especially noticeable during rapid queue updates (CSV import)
- **CSV Import**: Fixed CSV export not being parsed correctly
- Added support for `Artist Name(s)` header (with parentheses)
- Added support for `Track URI` header for track IDs
@@ -455,4 +495,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
---
*For older versions, see [GitHub Releases](https://github.com/zarzet/SpotiFLAC-Mobile/releases)*
*For older versions, see [GitHub Releases*](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
+74 -2
View File
@@ -5,6 +5,7 @@
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
-keep class io.flutter.embedding.** { *; }
# Ignore missing Play Core classes (not used, but referenced by Flutter)
-dontwarn com.google.android.play.core.splitcompat.**
@@ -14,13 +15,22 @@
# Ignore missing javax.xml.stream (not used on Android)
-dontwarn javax.xml.stream.**
# Go backend (gobackend.aar)
# Go backend (gobackend.aar) - CRITICAL for release builds
-keep class gobackend.** { *; }
-keep class go.** { *; }
-keep interface gobackend.** { *; }
-keepclassmembers class gobackend.** { *; }
# Go mobile binding internals
-keep class org.golang.** { *; }
-dontwarn org.golang.**
# FFmpeg Kit
-keep class com.arthenica.ffmpegkit.** { *; }
-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)
-dontwarn org.apache.tika.**
@@ -30,15 +40,77 @@
native <methods>;
}
# Kotlin coroutines
# Kotlin coroutines - expanded rules
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** {
volatile <fields>;
}
-keepclassmembernames class kotlinx.** {
volatile <fields>;
}
-dontwarn kotlinx.coroutines.**
# Kotlin serialization
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
-dontwarn kotlin.**
-keep class kotlin.** { *; }
-keep class kotlin.Metadata { *; }
# Keep MainActivity and related classes
-keep class com.zarz.spotiflac.** { *; }
# Prevent R8 from removing metadata
-keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable
-keepattributes Signature
-keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes EnclosingMethod
# JSON parsing (used by Go backend responses)
-keep class org.json.** { *; }
# Shared Preferences
-keep class androidx.datastore.** { *; }
-dontwarn androidx.datastore.**
# Flutter Plugins - CRITICAL: Prevent R8 from removing plugin implementations
# Path Provider
-keep class io.flutter.plugins.pathprovider.** { *; }
-keep class dev.flutter.pigeon.** { *; }
# Local Notifications
-keep class com.dexterous.** { *; }
-keep class com.dexterous.flutterlocalnotifications.** { *; }
# Receive Sharing Intent
-keep class com.kasem.receive_sharing_intent.** { *; }
# Permission Handler
-keep class com.baseflow.permissionhandler.** { *; }
# File Picker
-keep class com.mr.flutter.plugin.filepicker.** { *; }
# URL Launcher
-keep class io.flutter.plugins.urllauncher.** { *; }
# Share Plus
-keep class dev.fluttercommunity.plus.share.** { *; }
# Device Info Plus
-keep class dev.fluttercommunity.plus.device_info.** { *; }
# Open File
-keep class com.crazecoder.openfile.** { *; }
# Sqflite
-keep class com.tekartik.sqflite.** { *; }
# Dynamic Color
-keep class io.material.** { *; }
# Keep all Flutter plugin registrants
-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; }
-keep class ** extends io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
+29 -3
View File
@@ -20,8 +20,7 @@
android:label="SpotiFLAC"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true"
android:usesCleartextTraffic="false"
android:enableOnBackInvokedCallback="true">
<activity
@@ -43,7 +42,7 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Handle Spotify URL sharing -->
<!-- Handle music URL sharing (Spotify, Deezer, Tidal, YT Music) -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -57,6 +56,33 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="open.spotify.com" />
</intent-filter>
<!-- Handle Deezer deep links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="www.deezer.com" />
<data android:scheme="https" android:host="deezer.com" />
<data android:scheme="https" android:host="deezer.page.link" />
</intent-filter>
<!-- Handle Tidal deep links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="tidal.com" />
<data android:scheme="https" android:host="listen.tidal.com" />
</intent-filter>
<!-- Handle YouTube Music deep links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="music.youtube.com" />
</intent-filter>
</activity>
<!-- Download Service -->
@@ -431,6 +431,20 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"parseTidalUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseTidalURLExport(url)
}
result.success(response)
}
"convertTidalToSpotifyDeezer" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.convertTidalToSpotifyDeezer(url)
}
result.success(response)
}
"searchDeezerByISRC" -> {
val isrc = call.argument<String>("isrc") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -892,6 +906,40 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
// Local Library Scanning
"setLibraryCoverCacheDir" -> {
val cacheDir = call.argument<String>("cache_dir") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setLibraryCoverCacheDirJSON(cacheDir)
}
result.success(null)
}
"scanLibraryFolder" -> {
val folderPath = call.argument<String>("folder_path") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.scanLibraryFolderJSON(folderPath)
}
result.success(response)
}
"getLibraryScanProgress" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getLibraryScanProgressJSON()
}
result.success(response)
}
"cancelLibraryScan" -> {
withContext(Dispatchers.IO) {
Gobackend.cancelLibraryScanJSON()
}
result.success(null)
}
"readAudioMetadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.readAudioMetadataJSON(filePath)
}
result.success(response)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
+1 -1
View File
@@ -1,2 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+28 -16
View File
@@ -54,10 +54,7 @@ func NewAmazonDownloader() *AmazonDownloader {
return globalAmazonDownloader
}
// downloadFromAfkarXYZ downloads a track using AfkarXYZ API
// Returns: downloadURL, fileName, error
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
// AfkarXYZ API endpoint
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
@@ -98,7 +95,6 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin
fileName = "track.flac"
}
// Sanitize filename
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "")
@@ -110,7 +106,6 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
// Initialize item progress (required for all downloads)
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
@@ -162,7 +157,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
written, err = io.Copy(bufWriter, resp.Body)
}
// Flush buffer before checking for errors
flushErr := bufWriter.Flush()
closeErr := out.Close()
@@ -182,7 +176,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
@@ -206,7 +199,6 @@ type AmazonDownloadResult struct {
ISRC string
}
// downloadFromAmazon uses AfkarXYZ API to download from Amazon Music
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
@@ -299,6 +291,10 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
actualDate := req.ReleaseDate
actualAlbum := req.AlbumName
actualTitle := req.TrackName
actualArtist := req.ArtistName
if metaErr == nil && existingMeta != nil {
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
@@ -309,15 +305,24 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
actualDiscNum = existingMeta.DiscNumber
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
}
if existingMeta.Date != "" && req.ReleaseDate == "" {
actualDate = existingMeta.Date
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
}
if existingMeta.Album != "" && req.AlbumName == "" {
actualAlbum = existingMeta.Album
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
}
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
}
// Embed metadata using Spotify data
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
Title: actualTitle,
Artist: actualArtist,
Album: actualAlbum,
AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate,
Date: actualDate,
TrackNumber: actualTrackNum,
TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNum,
@@ -327,11 +332,18 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
Copyright: req.Copyright,
}
// Use cover data from parallel fetch
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil {
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
coverData = parallelResult.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 {
@@ -341,7 +353,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed" // default
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
File diff suppressed because it is too large Load Diff
+8 -38
View File
@@ -55,7 +55,7 @@ func GetDeezerClient() *DeezerClient {
type deezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
Duration int `json:"duration"` // in seconds
Duration int `json:"duration"`
TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
@@ -121,7 +121,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
AlbumArtist: track.Artist.Name,
DurationMS: track.Duration * 1000,
Images: albumImage,
ReleaseDate: releaseDate, // Added this
ReleaseDate: releaseDate,
TrackNumber: track.TrackPosition,
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
@@ -182,15 +182,12 @@ type deezerPlaylistFull struct {
} `json:"tracks"`
}
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
// filter can be: "" (all), "track", "artist", "album", "playlist"
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
albumLimit := 5 // Same as artistLimit for consistency
albumLimit := 5
playlistLimit := 5
// When filter is specified, increase limits for that type only
if filter != "" {
switch filter {
case "track":
@@ -233,7 +230,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
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)
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
@@ -263,7 +259,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
}
}
// Search 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)
@@ -296,7 +291,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
}
}
// Search albums
if albumLimit > 0 {
albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit)
GoLog("[Deezer] Fetching albums from: %s\n", albumURL)
@@ -358,7 +352,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
}
}
// Search playlists
if playlistLimit > 0 {
playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit)
GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL)
@@ -425,7 +418,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
return result, nil
}
// GetTrack fetches a single track by Deezer ID
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
@@ -439,7 +431,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
}, nil
}
// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
@@ -465,7 +456,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
artistName = strings.Join(names, ", ")
}
// Extract genres as comma-separated string
var genres []string
for _, g := range album.Genres.Data {
if g.Name != "" {
@@ -481,14 +471,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
Artists: artistName,
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
Images: albumImage,
Genre: genreStr, // From Deezer album
Label: album.Label, // From Deezer album
Genre: genreStr,
Label: album.Label,
}
// Fetch all tracks with pagination (Deezer default limit is 25)
allTracks := album.Tracks.Data
// If album has more tracks than returned, fetch remaining pages
if album.NbTracks > len(allTracks) {
GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
@@ -523,7 +511,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
// Normalize record_type (Deezer uses "compile" instead of "compilation")
albumType := album.RecordType
if albumType == "compile" {
albumType = "compilation"
@@ -533,7 +520,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr]
// Use track position from API, fallback to index+1 if not provided
trackNum := track.TrackPosition
if trackNum == 0 {
trackNum = i + 1
@@ -581,7 +567,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
}
c.cacheMu.RUnlock()
// Fetch artist info
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
var artist deezerArtistFull
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
@@ -596,7 +581,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
Popularity: 0,
}
// Fetch artist albums
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
var albumsResp struct {
Data []struct {
@@ -608,7 +592,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
RecordType string `json:"record_type"` // album, single, ep, compile
RecordType string `json:"record_type"`
} `json:"data"`
}
@@ -680,10 +664,8 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
info.Owner.Name = playlist.Title
info.Owner.Images = playlistImage
// Fetch all tracks with pagination (Deezer default limit is 25)
allTracks := playlist.Tracks.Data
// If playlist has more tracks than returned, fetch remaining pages
if playlist.NbTracks > len(allTracks) {
GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
@@ -789,7 +771,6 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
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 {
result := make(map[string]string, len(tracks))
var resultMu sync.Mutex
@@ -828,7 +809,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return result
}
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, deezerMaxParallelISRC)
var wg sync.WaitGroup
@@ -850,7 +830,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return
}
// Store in result and cache
resultMu.Lock()
result[trackIDStr] = fullTrack.ISRC
resultMu.Unlock()
@@ -865,7 +844,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return result
}
// Use this when you need ISRC for download
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
c.cacheMu.RLock()
if isrc, ok := c.isrcCache[trackID]; ok {
@@ -926,11 +904,10 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
}
type AlbumExtendedMetadata struct {
Genre string // Comma-separated list of genres
Label string // Record label name
Genre string
Label string
}
// Uses the album ID from a track to fetch extended metadata
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
if albumID == "" {
return nil, fmt.Errorf("empty album ID")
@@ -975,7 +952,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
return result, nil
}
// GetTrackAlbumID fetches the album ID for a Deezer track
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
@@ -987,7 +963,6 @@ func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (str
return fmt.Sprintf("%d", track.Album.ID), nil
}
// This is a convenience function that first gets the album ID, then fetches album metadata
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
albumID, err := c.GetTrackAlbumID(ctx, trackID)
if err != nil {
@@ -997,26 +972,22 @@ func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID
return c.GetAlbumExtendedMetadata(ctx, albumID)
}
// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label)
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
if isrc == "" {
return nil, fmt.Errorf("empty ISRC")
}
// First, search for track by ISRC
track, err := c.SearchByISRC(ctx, isrc)
if err != nil {
return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
}
// SpotifyID contains "deezer:123" format, extract the ID
deezerID := strings.TrimPrefix(track.SpotifyID, "deezer:")
if deezerID == "" {
return nil, fmt.Errorf("track found but no Deezer ID")
}
// Then fetch extended metadata using the Deezer track ID
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
}
@@ -1046,7 +1017,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
return json.Unmarshal(body, dst)
}
// parseDeezerURL is internal function, returns type and ID
func parseDeezerURL(input string) (string, string, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
+1 -18
View File
@@ -10,7 +10,6 @@ import (
"time"
)
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
type ISRCIndex struct {
index map[string]string // ISRC (uppercase) -> file path
outputDir string
@@ -25,8 +24,6 @@ var (
isrcIndexTTL = 5 * time.Minute
)
// GetISRCIndex returns or builds an ISRC index for the given directory
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first
isrcIndexCacheMu.RLock()
@@ -56,7 +53,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
return buildISRCIndex(outputDir)
}
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{
index: make(map[string]string),
@@ -91,7 +87,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
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))
isrcIndexCacheMu.Lock()
@@ -113,7 +109,6 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
return path, exists
}
// remove deletes an ISRC entry from the index (internal use)
func (idx *ISRCIndex) remove(isrc string) {
if isrc == "" {
return
@@ -125,14 +120,11 @@ func (idx *ISRCIndex) remove(isrc string) {
delete(idx.index, strings.ToUpper(isrc))
}
// Lookup checks if an ISRC exists in the index (gomobile compatible)
// Returns filepath if found, empty string if not found
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
path, _ := idx.lookup(isrc)
return path, nil
}
// Add adds a new ISRC to the index (call after successful download)
func (idx *ISRCIndex) Add(isrc, filePath string) {
if isrc == "" || filePath == "" {
return
@@ -144,15 +136,12 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
idx.index[strings.ToUpper(isrc)] = filePath
}
// InvalidateCache clears the ISRC index cache for a directory
func InvalidateISRCCache(outputDir string) {
isrcIndexCacheMu.Lock()
delete(isrcIndexCache, outputDir)
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) {
if isrc == "" || outputDir == "" {
return "", false
@@ -173,13 +162,11 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
return filePath, true
}
// CheckISRCExists is the exported version for gomobile (returns string, error)
func CheckISRCExists(outputDir, isrc string) (string, error) {
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
return filepath, nil
}
// CheckFileExists checks if a file with the given name exists
func CheckFileExists(filePath string) bool {
info, err := os.Stat(filePath)
if err != nil {
@@ -188,7 +175,6 @@ func CheckFileExists(filePath string) bool {
return !info.IsDir() && info.Size() > 0
}
// FileExistenceResult represents the result of checking if a file exists
type FileExistenceResult struct {
ISRC string `json:"isrc"`
Exists bool `json:"exists"`
@@ -249,8 +235,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
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 {
if outputDir == "" {
return fmt.Errorf("output directory is required")
@@ -260,7 +244,6 @@ func PreBuildISRCIndex(outputDir string) error {
return nil
}
// AddToISRCIndex adds a new file to the ISRC index after successful download
func AddToISRCIndex(outputDir, isrc, filePath string) {
if outputDir == "" || isrc == "" || filePath == "" {
return
+79 -80
View File
@@ -148,17 +148,16 @@ type DownloadRequest struct {
LyricsMode string `json:"lyrics_mode,omitempty"`
}
// DownloadResponse represents the result of a download
type DownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
ErrorType string `json:"error_type,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
Service string `json:"service,omitempty"` // Actual service used (for fallback)
Service string `json:"service,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
@@ -172,6 +171,7 @@ type DownloadResponse struct {
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
}
type DownloadResult struct {
@@ -185,6 +185,7 @@ type DownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
}
func DownloadTrack(requestJSON string) (string, error) {
@@ -193,7 +194,6 @@ func DownloadTrack(requestJSON string) (string, error) {
return errorResponse("Invalid request: " + err.Error())
}
// Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
@@ -222,6 +222,7 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: tidalResult.TrackNumber,
DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC,
LyricsLRC: tidalResult.LyricsLRC,
}
}
err = tidalErr
@@ -317,6 +318,7 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
LyricsLRC: result.LyricsLRC,
}
jsonBytes, _ := json.Marshal(resp)
@@ -380,6 +382,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: tidalResult.TrackNumber,
DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC,
LyricsLRC: tidalResult.LyricsLRC,
}
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
@@ -452,6 +455,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
LyricsLRC: result.LyricsLRC,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
@@ -480,6 +484,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
LyricsLRC: result.LyricsLRC,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
@@ -631,14 +636,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
}
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
// If filePath is provided, ONLY check file - don't fallback to online
// This allows Flutter to distinguish between "from file" vs "from online"
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
return lyrics, nil
}
// File has no lyrics - return empty, let Flutter call again without filePath
return "", nil
}
@@ -649,7 +651,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
return "", err
}
// Return special marker for instrumental tracks
if lyricsData.Instrumental {
return "[instrumental:true]", nil
}
@@ -734,9 +735,6 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
return string(jsonBytes), nil
}
// GetDeezerMetadata fetches metadata from Deezer URL or ID
// resourceType: track, album, artist, playlist
// resourceID: Deezer ID
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -770,7 +768,6 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
return string(jsonBytes), nil
}
// ParseDeezerURLExport parses a Deezer URL and returns type and ID
func ParseDeezerURLExport(url string) (string, error) {
resourceType, resourceID, err := parseDeezerURL(url)
if err != nil {
@@ -790,9 +787,51 @@ func ParseDeezerURLExport(url string) (string, error) {
return string(jsonBytes), nil
}
// GetDeezerExtendedMetadata fetches genre and label from Deezer album
// trackID: Deezer track ID (will look up album ID from track)
// Returns JSON with genre, label fields
func ParseTidalURLExport(url string) (string, error) {
resourceType, resourceID, err := parseTidalURL(url)
if err != nil {
return "", err
}
result := map[string]string{
"type": resourceType,
"id": resourceID,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func ConvertTidalToSpotifyDeezer(tidalURL string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckAvailabilityFromURL(tidalURL)
if err != nil {
return "", err
}
result := map[string]string{
"spotify_id": availability.SpotifyID,
"deezer_id": availability.DeezerID,
"deezer_url": availability.DeezerURL,
"spotify_url": "",
}
if availability.SpotifyID != "" {
result["spotify_url"] = "https://open.spotify.com/track/" + availability.SpotifyID
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetDeezerExtendedMetadata(trackID string) (string, error) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
@@ -821,7 +860,6 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
return string(jsonBytes), nil
}
// SearchDeezerByISRC searches for a track by ISRC on Deezer
func SearchDeezerByISRC(isrc string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -840,8 +878,6 @@ func SearchDeezerByISRC(isrc string) (string, error) {
return string(jsonBytes), nil
}
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
// Useful when Spotify API is rate limited
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -891,7 +927,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
}
// GetSpotifyMetadataWithDeezerFallback tries Spotify first, falls back to Deezer on rate limit
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -948,10 +983,6 @@ func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
return string(jsonBytes), nil
}
// CheckAvailabilityByPlatformID checks track availability using any platform as source
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube"
// entityType: "song" or "album"
// entityID: the ID on that platform
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
@@ -967,19 +998,16 @@ func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (strin
return string(jsonBytes), nil
}
// GetSpotifyIDFromDeezerTrack converts a Deezer track ID to Spotify track ID
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetSpotifyIDFromDeezer(deezerTrackID)
}
// GetTidalURLFromDeezerTrack converts a Deezer track ID to Tidal URL
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetTidalURLFromDeezer(deezerTrackID)
}
// GetAmazonURLFromDeezerTrack converts a Deezer track ID to Amazon Music URL
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetAmazonURLFromDeezer(deezerTrackID)
@@ -1029,7 +1057,6 @@ func errorResponse(msg string) (string, error) {
// ==================== EXTENSION SYSTEM ====================
// InitExtensionSystem initializes the extension system with directories
func InitExtensionSystem(extensionsDir, dataDir string) error {
manager := GetExtensionManager()
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
@@ -1044,7 +1071,6 @@ func InitExtensionSystem(extensionsDir, dataDir string) error {
return nil
}
// LoadExtensionsFromDir loads all extensions from a directory
func LoadExtensionsFromDir(dirPath string) (string, error) {
manager := GetExtensionManager()
loaded, errors := manager.LoadExtensionsFromDirectory(dirPath)
@@ -1066,7 +1092,6 @@ func LoadExtensionsFromDir(dirPath string) (string, error) {
return string(jsonBytes), nil
}
// LoadExtensionFromPath loads a single extension from a .spotiflac-ext file
func LoadExtensionFromPath(filePath string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.LoadExtensionFromFile(filePath)
@@ -1096,19 +1121,16 @@ func LoadExtensionFromPath(filePath string) (string, error) {
return string(jsonBytes), nil
}
// UnloadExtensionByID unloads an extension
func UnloadExtensionByID(extensionID string) error {
manager := GetExtensionManager()
return manager.UnloadExtension(extensionID)
}
// RemoveExtensionByID completely removes an extension (unload + delete files)
func RemoveExtensionByID(extensionID string) error {
manager := GetExtensionManager()
return manager.RemoveExtension(extensionID)
}
// UpgradeExtensionFromPath upgrades an existing extension from a new package file
func UpgradeExtensionFromPath(filePath string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.UpgradeExtension(filePath)
@@ -1137,25 +1159,21 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
return string(jsonBytes), nil
}
// CheckExtensionUpgradeFromPath checks if a package file is an upgrade for an existing extension
func CheckExtensionUpgradeFromPath(filePath string) (string, error) {
manager := GetExtensionManager()
return manager.CheckExtensionUpgradeJSON(filePath)
}
// GetInstalledExtensions returns all installed extensions as JSON
func GetInstalledExtensions() (string, error) {
manager := GetExtensionManager()
return manager.GetInstalledExtensionsJSON()
}
// SetExtensionEnabledByID enables or disables an extension
func SetExtensionEnabledByID(extensionID string, enabled bool) error {
manager := GetExtensionManager()
return manager.SetExtensionEnabled(extensionID, enabled)
}
// SetProviderPriorityJSON sets the provider priority order from JSON array
func SetProviderPriorityJSON(priorityJSON string) error {
var priority []string
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
@@ -1166,7 +1184,6 @@ func SetProviderPriorityJSON(priorityJSON string) error {
return nil
}
// GetProviderPriorityJSON returns the provider priority order as JSON
func GetProviderPriorityJSON() (string, error) {
priority := GetProviderPriority()
jsonBytes, err := json.Marshal(priority)
@@ -1176,7 +1193,6 @@ func GetProviderPriorityJSON() (string, error) {
return string(jsonBytes), nil
}
// SetMetadataProviderPriorityJSON sets the metadata provider priority order from JSON array
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
var priority []string
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
@@ -1187,7 +1203,6 @@ func SetMetadataProviderPriorityJSON(priorityJSON string) error {
return nil
}
// GetMetadataProviderPriorityJSON returns the metadata provider priority order as JSON
func GetMetadataProviderPriorityJSON() (string, error) {
priority := GetMetadataProviderPriority()
jsonBytes, err := json.Marshal(priority)
@@ -1197,7 +1212,6 @@ func GetMetadataProviderPriorityJSON() (string, error) {
return string(jsonBytes), nil
}
// GetExtensionSettingsJSON returns settings for an extension as JSON
func GetExtensionSettingsJSON(extensionID string) (string, error) {
store := GetExtensionSettingsStore()
settings := store.GetAll(extensionID)
@@ -1210,7 +1224,6 @@ func GetExtensionSettingsJSON(extensionID string) (string, error) {
return string(jsonBytes), nil
}
// SetExtensionSettingsJSON sets settings for an extension from JSON
func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
var settings map[string]interface{}
if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
@@ -1226,7 +1239,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
return manager.InitializeExtension(extensionID, settings)
}
// SearchTracksWithExtensionsJSON searches all extension metadata providers
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
manager := GetExtensionManager()
tracks, err := manager.SearchTracksWithExtensions(query, limit)
@@ -1242,7 +1254,6 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
return string(jsonBytes), nil
}
// DownloadWithExtensionsJSON downloads using extension providers with fallback
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
@@ -1262,14 +1273,11 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// CleanupExtensions unloads all extensions gracefully
func CleanupExtensions() {
manager := GetExtensionManager()
manager.UnloadAllExtensions()
}
// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler)
// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.)
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
manager := GetExtensionManager()
result, err := manager.InvokeAction(extensionID, actionName)
@@ -1285,7 +1293,6 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
return string(jsonBytes), nil
}
// GetExtensionPendingAuthJSON returns pending auth request for an extension
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
req := GetPendingAuthRequest(extensionID)
if req == nil {
@@ -1306,12 +1313,10 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
return string(jsonBytes), nil
}
// SetExtensionAuthCodeByID sets auth code for an extension (called from Flutter after OAuth callback)
func SetExtensionAuthCodeByID(extensionID, authCode string) {
SetExtensionAuthCode(extensionID, authCode)
}
// SetExtensionTokensByID sets tokens for an extension
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
var expiresAt time.Time
if expiresIn > 0 {
@@ -1320,12 +1325,10 @@ func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expir
SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt)
}
// ClearExtensionPendingAuthByID clears pending auth request for an extension
func ClearExtensionPendingAuthByID(extensionID string) {
ClearPendingAuthRequest(extensionID)
}
// IsExtensionAuthenticatedByID checks if an extension is authenticated
func IsExtensionAuthenticatedByID(extensionID string) bool {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -1342,7 +1345,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool {
return state.IsAuthenticated
}
// GetAllPendingAuthRequestsJSON returns all pending auth requests
func GetAllPendingAuthRequestsJSON() (string, error) {
pendingAuthRequestsMu.RLock()
defer pendingAuthRequestsMu.RUnlock()
@@ -1386,12 +1388,10 @@ func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
return string(jsonBytes), nil
}
// SetFFmpegCommandResultByID sets the result of an FFmpeg command
func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) {
SetFFmpegCommandResult(commandID, success, output, errorMsg)
}
// GetAllPendingFFmpegCommandsJSON returns all pending FFmpeg commands
func GetAllPendingFFmpegCommandsJSON() (string, error) {
ffmpegCommandsMu.RLock()
defer ffmpegCommandsMu.RUnlock()
@@ -1417,8 +1417,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
// ==================== EXTENSION CUSTOM SEARCH ====================
// EnrichTrackWithExtensionJSON enriches track metadata using the source extension
// This is called lazily before download starts, allowing extension to fetch real ISRC etc.
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -1449,7 +1447,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
return string(jsonBytes), nil
}
// CustomSearchWithExtensionJSON performs custom search using an extension
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -1489,8 +1486,8 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType, // track, album, or playlist
"album_type": track.AlbumType, // album, single, ep, compilation
"item_type": track.ItemType,
"album_type": track.AlbumType,
}
}
@@ -1502,7 +1499,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
return string(jsonBytes), nil
}
// GetSearchProvidersJSON returns all extensions that provide custom search
func GetSearchProvidersJSON() (string, error) {
manager := GetExtensionManager()
providers := manager.GetSearchProviders()
@@ -1587,7 +1583,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
response["tracks"] = tracks
}
// Add album info if present
if result.Album != nil {
response["album"] = map[string]interface{}{
"id": result.Album.ID,
@@ -1666,8 +1661,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
return string(jsonBytes), nil
}
// FindURLHandlerJSON finds an extension that can handle the given URL
// Returns extension ID or empty string if none found
func FindURLHandlerJSON(url string) string {
manager := GetExtensionManager()
handler := manager.FindURLHandler(url)
@@ -1677,7 +1670,6 @@ func FindURLHandlerJSON(url string) string {
return handler.extension.ID
}
// GetAlbumWithExtensionJSON gets album tracks using an extension
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -1708,7 +1700,6 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
if trackCover == "" {
trackCover = album.CoverURL
}
// Use track number from extension, fallback to index+1 if not provided
trackNum := track.TrackNumber
if trackNum == 0 {
trackNum = i + 1
@@ -1752,7 +1743,6 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
return string(jsonBytes), nil
}
// GetPlaylistWithExtensionJSON gets playlist tracks using an extension
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -1844,7 +1834,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
return string(jsonBytes), nil
}
// GetArtistWithExtensionJSON gets artist info and albums using an extension
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -1888,7 +1877,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
"provider_id": artist.ProviderID,
}
// Add header image if present
if artist.HeaderImage != "" {
response["header_image"] = artist.HeaderImage
}
@@ -1897,7 +1885,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
response["listeners"] = artist.Listeners
}
// Add top tracks if present
if len(artist.TopTracks) > 0 {
topTracks := make([]map[string]interface{}, len(artist.TopTracks))
for i, track := range artist.TopTracks {
@@ -1928,7 +1915,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
return string(jsonBytes), nil
}
// GetURLHandlersJSON returns all extensions that handle custom URLs
func GetURLHandlersJSON() (string, error) {
manager := GetExtensionManager()
handlers := manager.GetURLHandlers()
@@ -1972,7 +1958,6 @@ func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
// GetPostProcessingProvidersJSON returns all extensions that provide post-processing
func GetPostProcessingProvidersJSON() (string, error) {
manager := GetExtensionManager()
providers := manager.GetPostProcessingProviders()
@@ -2005,13 +1990,11 @@ func GetPostProcessingProvidersJSON() (string, error) {
return string(jsonBytes), nil
}
// InitExtensionStoreJSON initializes the extension store with cache directory
func InitExtensionStoreJSON(cacheDir string) error {
InitExtensionStore(cacheDir)
return nil
}
// GetStoreExtensionsJSON returns all extensions from the store with installation status
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
store := GetExtensionStore()
if store == nil {
@@ -2035,7 +2018,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
return string(jsonBytes), nil
}
// SearchStoreExtensionsJSON searches extensions in the store
func SearchStoreExtensionsJSON(query, category string) (string, error) {
store := GetExtensionStore()
if store == nil {
@@ -2055,7 +2037,6 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) {
return string(jsonBytes), nil
}
// GetStoreCategoriesJSON returns all available categories
func GetStoreCategoriesJSON() (string, error) {
store := GetExtensionStore()
if store == nil {
@@ -2071,8 +2052,6 @@ func GetStoreCategoriesJSON() (string, error) {
return string(jsonBytes), nil
}
// DownloadStoreExtensionJSON downloads an extension from the store
// Returns the path to the downloaded file
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
store := GetExtensionStore()
if store == nil {
@@ -2088,7 +2067,6 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
return destPath, nil
}
// ClearStoreCacheJSON clears the store cache
func ClearStoreCacheJSON() error {
store := GetExtensionStore()
if store == nil {
@@ -2139,12 +2117,33 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
return string(jsonBytes), nil
}
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
}
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
}
// ==================== LOCAL LIBRARY SCANNING ====================
// SetLibraryCoverCacheDirJSON sets the directory for caching extracted cover art
func SetLibraryCoverCacheDirJSON(cacheDir string) {
SetLibraryCoverCacheDir(cacheDir)
}
func ScanLibraryFolderJSON(folderPath string) (string, error) {
return ScanLibraryFolder(folderPath)
}
func GetLibraryScanProgressJSON() string {
return GetLibraryScanProgress()
}
func CancelLibraryScanJSON() {
CancelLibraryScan()
}
func ReadAudioMetadataJSON(filePath string) (string, error) {
return ReadAudioMetadata(filePath)
}
+5 -32
View File
@@ -47,7 +47,7 @@ type LoadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access
VMMu sync.Mutex `json:"-"`
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
@@ -55,12 +55,11 @@ type LoadedExtension struct {
IconPath string `json:"icon_path"`
}
// ExtensionManager manages all loaded extensions
type ExtensionManager struct {
mu sync.RWMutex
extensions map[string]*LoadedExtension
extensionsDir string // Base directory for extensions
dataDir string // Base directory for extension data
extensionsDir string
dataDir string
}
var (
@@ -99,7 +98,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
// Open the zip file
zipReader, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
@@ -222,7 +220,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
SourceDir: extDir,
}
// Initialize Goja VM
if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
@@ -269,13 +266,11 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
return goja.Undefined()
})
// Run the extension code
_, err = vm.RunString(string(jsCode))
if err != nil {
return fmt.Errorf("failed to execute extension code: %w", err)
}
// Verify extension was registered
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
return fmt.Errorf("extension did not call registerExtension()")
}
@@ -283,7 +278,6 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
return nil
}
// UnloadExtension unloads an extension by ID
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -293,9 +287,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return fmt.Errorf("Extension not found")
}
// Call cleanup if VM is initialized
if ext.VM != nil {
// Try to call cleanup function
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
if err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
@@ -304,14 +296,12 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
}
}
// Remove from registry
delete(m.extensions, extensionID)
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
return nil
}
// Returns error if extension not found (gomobile compatible)
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -323,7 +313,6 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
return ext, nil
}
// GetAllExtensions returns all loaded extensions
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -347,7 +336,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
ext.Enabled = enabled
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
// Persist enabled state to settings store
store := GetExtensionSettingsStore()
if err := store.Set(extensionID, "_enabled", enabled); err != nil {
GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err)
@@ -356,7 +344,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return nil
}
// LoadExtensionsFromDirectory scans a directory and loads all valid extensions
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
var loaded []string
var errors []error
@@ -443,7 +430,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
}
}
// Initialize Goja VM
if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
@@ -456,19 +442,16 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return ext, nil
}
// RemoveExtension completely removes an extension (unload + delete files)
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
ext, err := m.GetExtension(extensionID)
if err != nil {
return err
}
// Unload first
if err := m.UnloadExtension(extensionID); err != nil {
return err
}
// Remove source directory
if ext.SourceDir != "" {
if err := os.RemoveAll(ext.SourceDir); err != nil {
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
@@ -490,7 +473,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
// Open the zip file
zipReader, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
@@ -554,11 +536,9 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
extDir := existing.SourceDir
wasEnabled := existing.Enabled
// Cleanup and unload existing extension
m.CleanupExtension(existing.ID)
m.UnloadExtension(existing.ID)
// Remove old source files but keep data directory
if extDir != "" {
if err := os.RemoveAll(extDir); err != nil {
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
@@ -637,16 +617,14 @@ type ExtensionUpgradeInfo struct {
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) {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format")
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
// Open the zip file
zipReader, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("Cannot open extension file")
}
@@ -714,7 +692,6 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
return string(jsonBytes), nil
}
// GetInstalledExtensionsJSON returns all extensions as JSON for Flutter
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions()
@@ -809,8 +786,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
return string(jsonBytes), nil
}
// ==================== Extension Lifecycle ====================
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -923,7 +898,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
return nil
}
// UnloadAllExtensions unloads all extensions gracefully
func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Lock()
extensionIDs := make([]string, 0, len(m.extensions))
@@ -940,7 +914,6 @@ func (m *ExtensionManager) UnloadAllExtensions() {
GoLog("[Extension] All extensions unloaded\n")
}
// The function is called as extension.<actionName>() and can return a result
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
m.mu.Lock()
defer m.mu.Unlock()
+43 -71
View File
@@ -7,7 +7,6 @@ import (
"strings"
)
// ExtensionType represents the type of extension
type ExtensionType string
const (
@@ -15,7 +14,6 @@ const (
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
)
// SettingType represents the type of a setting field
type SettingType string
const (
@@ -26,14 +24,12 @@ const (
SettingTypeButton SettingType = "button" // Action button that calls a JS function
)
// ExtensionPermissions defines what resources an extension can access
type ExtensionPermissions struct {
Network []string `json:"network"` // List of allowed domains
Storage bool `json:"storage"` // Whether extension can use storage API
File bool `json:"file"` // Whether extension can use file API
Network []string `json:"network"`
Storage bool `json:"storage"`
File bool `json:"file"`
}
// ExtensionSetting defines a configurable setting for an extension
type ExtensionSetting struct {
Key string `json:"key"`
Type SettingType `json:"type"`
@@ -42,19 +38,17 @@ type ExtensionSetting struct {
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
Options []string `json:"options,omitempty"`
Action string `json:"action,omitempty"`
}
// QualityOption represents a quality option for download providers
type QualityOption struct {
ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128")
Label string `json:"label"` // Display name (e.g., "MP3 320kbps")
Description string `json:"description"` // Optional description (e.g., "Best quality MP3")
Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description"`
Settings []QualitySpecificSetting `json:"settings,omitempty"`
}
// QualitySpecificSetting represents a setting that's specific to a quality option
type QualitySpecificSetting struct {
Key string `json:"key"`
Type SettingType `json:"type"`
@@ -63,57 +57,50 @@ type QualitySpecificSetting struct {
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type
Options []string `json:"options,omitempty"`
}
// SearchFilter defines a filter option for search
type SearchFilter struct {
ID string `json:"id"` // Filter identifier (e.g., "track", "album", "artist", "playlist")
Label string `json:"label,omitempty"` // Display label (e.g., "Songs", "Albums", "Artists", "Playlists")
Icon string `json:"icon,omitempty"` // Optional icon name
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 {
Enabled bool `json:"enabled"` // Whether extension provides custom search
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab
Icon string `json:"icon,omitempty"` // Icon for search tab
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3)
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
Filters []SearchFilter `json:"filters,omitempty"` // Available search filters (e.g., track, album, artist, playlist)
Enabled bool `json:"enabled"`
Placeholder string `json:"placeholder,omitempty"`
Primary bool `json:"primary,omitempty"`
Icon string `json:"icon,omitempty"`
ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
Filters []SearchFilter `json:"filters,omitempty"`
}
// URLHandlerConfig defines custom URL handling for an extension
type URLHandlerConfig struct {
Enabled bool `json:"enabled"` // Whether extension handles URLs
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com")
Enabled bool `json:"enabled"`
Patterns []string `json:"patterns,omitempty"`
}
// TrackMatchingConfig defines custom track matching behavior
type TrackMatchingConfig struct {
CustomMatching bool `json:"customMatching"` // Whether extension handles matching
Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom"
DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching
CustomMatching bool `json:"customMatching"`
Strategy string `json:"strategy,omitempty"`
DurationTolerance int `json:"durationTolerance,omitempty"`
}
// PostProcessingHook defines a post-processing hook
type PostProcessingHook struct {
ID string `json:"id"` // Unique identifier
Name string `json:"name"` // Display name
Description string `json:"description,omitempty"` // Description
DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default
SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"])
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
DefaultEnabled bool `json:"defaultEnabled,omitempty"`
SupportedFormats []string `json:"supportedFormats,omitempty"`
}
// PostProcessingConfig defines post-processing capabilities
type PostProcessingConfig struct {
Enabled bool `json:"enabled"` // Whether extension provides post-processing
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks
Enabled bool `json:"enabled"`
Hooks []PostProcessingHook `json:"hooks,omitempty"`
}
// ExtensionManifest represents the manifest.json of an extension
type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
@@ -121,22 +108,21 @@ type ExtensionManifest struct {
Author string `json:"author"`
Description string `json:"description"`
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"`
Permissions ExtensionPermissions `json:"permissions"`
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"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
// ManifestValidationError represents a validation error in the manifest
type ManifestValidationError struct {
Field string
Message string
@@ -146,7 +132,6 @@ func (e *ManifestValidationError) Error() string {
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) {
var manifest ExtensionManifest
if err := json.Unmarshal(data, &manifest); err != nil {
@@ -190,7 +175,6 @@ func (m *ExtensionManifest) Validate() error {
}
}
// Validate settings if present
for i, setting := range m.Settings {
if strings.TrimSpace(setting.Key) == "" {
return &ManifestValidationError{
@@ -225,7 +209,6 @@ func (m *ExtensionManifest) Validate() error {
return nil
}
// HasType checks if the extension has a specific type
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
for _, et := range m.Types {
if et == t {
@@ -235,17 +218,14 @@ func (m *ExtensionManifest) HasType(t ExtensionType) bool {
return false
}
// IsMetadataProvider returns true if extension provides metadata
func (m *ExtensionManifest) IsMetadataProvider() bool {
return m.HasType(ExtensionTypeMetadataProvider)
}
// IsDownloadProvider returns true if extension provides downloads
func (m *ExtensionManifest) IsDownloadProvider() bool {
return m.HasType(ExtensionTypeDownloadProvider)
}
// IsDomainAllowed checks if a domain is in the allowed network permissions
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain))
for _, allowed := range m.Permissions.Network {
@@ -255,7 +235,7 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
}
// Support wildcard subdomains (e.g., *.example.com)
if strings.HasPrefix(allowed, "*.") {
suffix := allowed[1:] // Remove the *
suffix := allowed[1:]
if strings.HasSuffix(domain, suffix) {
return true
}
@@ -264,27 +244,22 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
return false
}
// HasCustomSearch returns true if extension provides custom search
func (m *ExtensionManifest) HasCustomSearch() bool {
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
}
// HasCustomMatching returns true if extension provides custom track matching
func (m *ExtensionManifest) HasCustomMatching() bool {
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
}
// HasPostProcessing returns true if extension provides post-processing
func (m *ExtensionManifest) HasPostProcessing() bool {
return m.PostProcessing != nil && m.PostProcessing.Enabled
}
// HasURLHandler returns true if extension handles custom URLs
func (m *ExtensionManifest) HasURLHandler() bool {
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 {
if !m.HasURLHandler() {
return false
@@ -293,7 +268,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
for _, pattern := range m.URLHandler.Patterns {
pattern = strings.ToLower(strings.TrimSpace(pattern))
// Check if URL contains the pattern (host match)
if strings.Contains(urlStr, pattern) {
return true
}
@@ -301,7 +275,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
return false
}
// GetPostProcessingHooks returns all post-processing hooks
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
if m.PostProcessing == nil {
return nil
@@ -309,7 +282,6 @@ func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
return m.PostProcessing.Hooks
}
// ToJSON serializes the manifest to JSON
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
return json.Marshal(m)
}
+44 -160
View File
@@ -25,27 +25,26 @@ type ExtTrackMetadata struct {
AlbumArtist string `json:"album_artist,omitempty"`
DurationMS int `json:"duration_ms"`
CoverURL string `json:"cover_url,omitempty"`
Images string `json:"images,omitempty"` // Alternative field for cover URL (used by some extensions)
Images string `json:"images,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
ISRC string `json:"isrc,omitempty"`
ProviderID string `json:"provider_id"`
ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
// Enrichment fields from Odesli/song.link
ItemType string `json:"item_type,omitempty"`
AlbumType string `json:"album_type,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
SpotifyID string `json:"spotify_id,omitempty"`
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
// Extended metadata from enrichment (can come from Deezer, Spotify, etc.)
Label string `json:"label,omitempty"` // Record label
Copyright string `json:"copyright,omitempty"` // Copyright information
Genre string `json:"genre,omitempty"` // Music genre(s)
ExternalLinks map[string]string `json:"external_links,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Genre string `json:"genre,omitempty"`
}
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
if t.CoverURL != "" {
return t.CoverURL
@@ -53,7 +52,6 @@ func (t *ExtTrackMetadata) ResolvedCoverURL() string {
return t.Images
}
// ExtAlbumMetadata represents album metadata from an extension
type ExtAlbumMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -67,34 +65,28 @@ type ExtAlbumMetadata struct {
ProviderID string `json:"provider_id"`
}
// ExtArtistMetadata represents artist metadata from an extension
type ExtArtistMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
ImageURL string `json:"image_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background
Listeners int `json:"listeners,omitempty"` // Monthly listeners
HeaderImage string `json:"header_image,omitempty"`
Listeners int `json:"listeners,omitempty"`
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` // Popular tracks
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
ProviderID string `json:"provider_id"`
}
// ExtSearchResult represents search results from an extension
type ExtSearchResult struct {
Tracks []ExtTrackMetadata `json:"tracks"`
Total int `json:"total"`
}
// ==================== Download Types ====================
// ExtAvailabilityResult represents availability check result
type ExtAvailabilityResult struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
TrackID string `json:"track_id,omitempty"`
}
// ExtDownloadURLResult represents download URL info
type ExtDownloadURLResult struct {
URL string `json:"url"`
Format string `json:"format"`
@@ -102,7 +94,6 @@ type ExtDownloadURLResult struct {
SampleRate int `json:"sample_rate,omitempty"`
}
// ExtDownloadResult represents download result from an extension
type ExtDownloadResult struct {
Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"`
@@ -110,7 +101,7 @@ type ExtDownloadResult struct {
SampleRate int `json:"sample_rate,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
// Metadata returned by extension (optional - if provided, can skip enrichment)
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
@@ -122,15 +113,11 @@ type ExtDownloadResult struct {
ISRC string `json:"isrc,omitempty"`
}
// ==================== Provider Wrapper ====================
// ExtensionProviderWrapper wraps an extension to call its provider methods
type ExtensionProviderWrapper struct {
extension *LoadedExtension
vm *goja.Runtime
}
// NewExtensionProviderWrapper creates a new provider wrapper
func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper {
return &ExtensionProviderWrapper{
extension: ext,
@@ -138,9 +125,6 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
}
}
// ==================== Metadata Provider Methods ====================
// SearchTracks searches for tracks using the extension
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -150,11 +134,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Call extension's searchTracks function
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.searchTracks === 'function') {
@@ -176,7 +158,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return nil, fmt.Errorf("searchTracks returned null")
}
// Convert result to Go struct
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
@@ -185,14 +166,11 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
var searchResult ExtSearchResult
// Try to parse as ExtSearchResult object first
if err := json.Unmarshal(jsonBytes, &searchResult); err != nil {
// If that fails, try parsing as array of tracks directly
var tracks []ExtTrackMetadata
if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil {
return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr)
}
// Wrap array in ExtSearchResult
searchResult = ExtSearchResult{
Tracks: tracks,
Total: len(tracks),
@@ -206,7 +184,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return &searchResult, nil
}
// GetTrack gets track details by ID
func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -216,7 +193,6 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -256,7 +232,6 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
return &track, nil
}
// GetAlbum gets album details by ID
func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -266,7 +241,6 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -309,7 +283,6 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
return &album, nil
}
// GetArtist gets artist details by ID
func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -319,7 +292,6 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -359,27 +331,22 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return &artist, nil
}
// EnrichTrack enriches track metadata before download (e.g., fetch real ISRC)
// This is called lazily when download starts, not when playlist/album is loaded
// Extension should implement enrichTrack(track) function that returns enriched track
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return track, nil // Not a metadata provider, return as-is
return track, nil
}
if !p.extension.Enabled {
return track, nil // Extension disabled, return as-is
return track, nil
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Convert track to JSON for passing to JS
trackJSON, err := json.Marshal(track)
if err != nil {
GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err)
return track, nil // Return original on error
return track, nil
}
script := fmt.Sprintf(`
@@ -399,10 +366,9 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
} else {
GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err)
}
return track, nil // Return original on error
return track, nil
}
// If extension doesn't implement enrichTrack or returns null, return original
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return track, nil
}
@@ -420,18 +386,11 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return track, nil
}
// Preserve provider ID
enrichedTrack.ProviderID = track.ProviderID
GoLog("[Extension] EnrichTrack: enriched track from %s (ISRC: %s -> %s)\n",
p.extension.ID, track.ISRC, enrichedTrack.ISRC)
return &enrichedTrack, nil
}
// ==================== Download Provider Methods ====================
// CheckAvailability checks if a track is available for download
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
@@ -441,7 +400,6 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -480,7 +438,6 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
return &availability, nil
}
// GetDownloadURL gets the download URL for a track
func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
@@ -490,7 +447,6 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -529,10 +485,8 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return &urlResult, nil
}
// ExtDownloadTimeout is longer for extension download operations (5 minutes)
const ExtDownloadTimeout = 5 * time.Minute
// Download downloads a track with progress reporting
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
@@ -542,15 +496,12 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Set up progress callback in VM
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
percent := int(call.Arguments[0].ToInteger())
// Clamp to 0-100
if percent < 0 {
percent = 0
}
@@ -573,7 +524,6 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
})()
`, trackID, quality, outputPath)
// Use longer timeout for downloads (5 minutes)
result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout)
if err != nil {
errMsg := err.Error()
@@ -619,9 +569,6 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
return &downloadResult, nil
}
// ==================== Extension Manager Provider Methods ====================
// GetMetadataProviders returns all enabled metadata provider extensions
func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -635,7 +582,6 @@ func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
return providers
}
// GetDownloadProviders returns all enabled download provider extensions
func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -649,7 +595,6 @@ func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
return providers
}
// SearchTracksWithExtensions searches all metadata providers
func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
providers := m.GetMetadataProviders()
if len(providers) == 0 {
@@ -671,18 +616,12 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
return allTracks, nil
}
// ==================== Provider Priority ====================
// providerPriority stores the order of download providers
var providerPriority []string
var providerPriorityMu sync.RWMutex
// metadataProviderPriority stores the order of metadata providers
var metadataProviderPriority []string
var metadataProviderPriorityMu sync.RWMutex
// SetProviderPriority sets the order of download providers
// providerIDs should include both built-in ("tidal", "qobuz", "amazon") and extension IDs
func SetProviderPriority(providerIDs []string) {
providerPriorityMu.Lock()
defer providerPriorityMu.Unlock()
@@ -690,13 +629,11 @@ func SetProviderPriority(providerIDs []string) {
GoLog("[Extension] Download provider priority set: %v\n", providerIDs)
}
// GetProviderPriority returns the current provider priority order
func GetProviderPriority() []string {
providerPriorityMu.RLock()
defer providerPriorityMu.RUnlock()
if len(providerPriority) == 0 {
// Default order: built-in providers first
return []string{"tidal", "qobuz", "amazon"}
}
@@ -705,8 +642,6 @@ func GetProviderPriority() []string {
return result
}
// SetMetadataProviderPriority sets the order of metadata providers
// providerIDs should include both built-in ("spotify", "deezer") and extension IDs
func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock()
@@ -714,13 +649,11 @@ func SetMetadataProviderPriority(providerIDs []string) {
GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
}
// GetMetadataProviderPriority returns the current metadata provider priority order
func GetMetadataProviderPriority() []string {
metadataProviderPriorityMu.RLock()
defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 {
// Default order: built-in providers first
return []string{"deezer", "spotify"}
}
@@ -729,30 +662,34 @@ func GetMetadataProviderPriority() []string {
return result
}
// isBuiltInProvider checks if a provider ID is a built-in provider
func isBuiltInProvider(providerID string) bool {
switch providerID {
case "tidal", "qobuz", "amazon":
case "tidal", "qobuz", "amazon", "deezer":
return true
default:
return false
}
}
// ==================== Download with Fallback ====================
// DownloadWithExtensionFallback tries to download from providers in priority order
// Includes both built-in providers and extension providers
// If req.Source is set (extension ID), that extension is tried first
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
priority := GetProviderPriority()
extManager := GetExtensionManager()
var lastErr error
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
if req.Service != "" && isBuiltInProvider(req.Service) {
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
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 skipBuiltIn bool
// LAZY ENRICHMENT: If track came from an extension, try to enrich metadata (e.g., get real ISRC)
// This is done lazily at download time, not when playlist/album is loaded
if req.Source != "" && !isBuiltInProvider(req.Source) {
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
@@ -796,7 +733,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if enrichedTrack.Artists != "" {
req.ArtistName = enrichedTrack.Artists
}
// Copy extended metadata from enrichment (label, copyright, genre, release_date)
if enrichedTrack.Label != "" && req.Label == "" {
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
req.Label = enrichedTrack.Label
@@ -817,7 +753,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
// If source extension is specified, try it first before the priority list
if req.Source != "" && !isBuiltInProvider(req.Source) {
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
@@ -827,15 +762,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
provider := NewExtensionProviderWrapper(ext)
// For tracks from extension search, use the track ID directly (e.g., "youtube:VIDEO_ID")
// The extension already knows how to handle this ID
trackID := req.SpotifyID // This contains the extension's track ID (e.g., "youtube:xxx")
trackID := req.SpotifyID
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
outputPath := buildOutputPath(req)
// Download directly using the track ID from the extension
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
if req.ItemID != "" {
SetItemProgress(req.ItemID, float64(percent), 0, 0)
@@ -855,7 +787,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Copyright: req.Copyright,
}
// Embed genre and label if provided (from Deezer metadata)
if req.Genre != "" || req.Label != "" {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
@@ -864,7 +795,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
// If extension has skipMetadataEnrichment, copy metadata
if ext.Manifest.SkipMetadataEnrichment {
resp.SkipMetadataEnrichment = true
if result.Title != "" {
@@ -914,12 +844,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
// If skipBuiltInFallback is true, don't continue to other providers
if skipBuiltIn {
GoLog("[DownloadWithExtensionFallback] skipBuiltInFallback is true, not trying other providers\n")
return &DownloadResponse{
Success: false,
Error: fmt.Sprintf("Download failed: %v", lastErr),
Error: "Download failed: " + lastErr.Error(),
ErrorType: "extension_error",
Service: req.Source,
}, nil
@@ -929,14 +858,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
// Continue with priority list
for _, providerID := range priority {
// Skip if we already tried this as source
if providerID == req.Source {
continue
}
// Skip built-in providers if skipBuiltIn is set
if skipBuiltIn && isBuiltInProvider(providerID) {
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
continue
@@ -945,7 +871,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerID) {
// For built-in providers, enrich with Deezer metadata if not already present
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -966,11 +891,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
// Use built-in provider
result, err := tryBuiltInProvider(providerID, req)
if err == nil && result.Success {
result.Service = providerID
// Copy enriched metadata to response for Flutter (needed for M4A->FLAC conversion)
if req.Label != "" {
result.Label = req.Label
}
@@ -998,7 +921,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
}
} else {
// Try extension provider
ext, err := extManager.GetExtension(providerID)
if err != nil || !ext.Enabled || ext.Error != "" {
GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID)
@@ -1041,7 +963,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Copyright: req.Copyright,
}
// Embed genre and label if provided (from Deezer metadata)
if req.Genre != "" || req.Label != "" {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
@@ -1050,10 +971,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
// If extension has skipMetadataEnrichment and returned metadata, use it
if ext.Manifest.SkipMetadataEnrichment {
resp.SkipMetadataEnrichment = true
// Copy metadata from extension result if provided
if result.Title != "" {
resp.Title = result.Title
}
@@ -1106,7 +1025,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if lastErr != nil {
return &DownloadResponse{
Success: false,
Error: fmt.Sprintf("All providers failed. Last error: %v", lastErr),
Error: "All providers failed. Last error: " + lastErr.Error(),
ErrorType: "not_found",
}, nil
}
@@ -1118,7 +1037,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}, nil
}
// tryBuiltInProvider attempts download from a built-in provider
func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) {
req.Service = providerID
@@ -1204,7 +1122,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
}, nil
}
// buildOutputPath builds the output file path from request
func buildOutputPath(req DownloadRequest) string {
metadata := map[string]interface{}{
"title": req.TrackName,
@@ -1224,9 +1141,6 @@ func buildOutputPath(req DownloadRequest) string {
return fmt.Sprintf("%s/%s.flac", req.OutputDir, filename)
}
// ==================== Custom Search ====================
// CustomSearch performs a custom search using an extension's search function
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
if !p.extension.Manifest.HasCustomSearch() {
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
@@ -1236,11 +1150,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Convert options to JSON
optionsJSON, _ := json.Marshal(options)
script := fmt.Sprintf(`
@@ -1261,7 +1173,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
// Return empty array instead of error for no results
return []ExtTrackMetadata{}, nil
}
@@ -1276,7 +1187,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
return nil, fmt.Errorf("failed to parse search result: %w", err)
}
// Return empty array if no tracks found
if tracks == nil {
tracks = []ExtTrackMetadata{}
}
@@ -1288,20 +1198,16 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
return tracks, nil
}
// ==================== Custom URL Handler ====================
// ExtURLHandleResult represents the result of URL handling
type ExtURLHandleResult struct {
Type string `json:"type"` // "track", "album", "playlist", "artist"
Track *ExtTrackMetadata `json:"track,omitempty"` // For single track
Tracks []ExtTrackMetadata `json:"tracks,omitempty"` // For album/playlist
Album *ExtAlbumMetadata `json:"album,omitempty"` // Album info
Artist *ExtArtistMetadata `json:"artist,omitempty"` // Artist info
Name string `json:"name,omitempty"` // Playlist/album name
CoverURL string `json:"cover_url,omitempty"` // Cover image
Type string `json:"type"`
Track *ExtTrackMetadata `json:"track,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
Album *ExtAlbumMetadata `json:"album,omitempty"`
Artist *ExtArtistMetadata `json:"artist,omitempty"`
Name string `json:"name,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
}
// HandleURL processes a URL using the extension's URL handler
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
if !p.extension.Manifest.HasURLHandler() {
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
@@ -1311,7 +1217,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -1347,7 +1252,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return nil, fmt.Errorf("failed to parse URL handle result: %w", err)
}
// Set provider ID on tracks
if handleResult.Track != nil {
handleResult.Track.ProviderID = p.extension.ID
}
@@ -1376,9 +1280,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return &handleResult, nil
}
// ==================== Custom Track Matching ====================
// MatchTrackResult represents the result of custom track matching
type MatchTrackResult struct {
Matched bool `json:"matched"`
TrackID string `json:"track_id,omitempty"`
@@ -1386,7 +1287,6 @@ type MatchTrackResult struct {
Reason string `json:"reason,omitempty"`
}
// MatchTrack uses extension's custom matching algorithm
func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
if !p.extension.Manifest.HasCustomMatching() {
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
@@ -1396,7 +1296,6 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -1438,22 +1337,16 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
return &matchResult, nil
}
// ==================== Post-Processing ====================
// PostProcessResult represents the result of post-processing
type PostProcessResult struct {
Success bool `json:"success"`
NewFilePath string `json:"new_file_path,omitempty"`
Error string `json:"error,omitempty"`
// Additional metadata that may have changed
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
}
// PostProcessTimeout is longer for post-processing (2 minutes)
const PostProcessTimeout = 2 * time.Minute
// PostProcess runs post-processing hooks on a downloaded file
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() {
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
@@ -1463,7 +1356,6 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -1517,9 +1409,6 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return &postResult, nil
}
// ==================== Extension Manager Advanced Methods ====================
// GetSearchProviders returns all extensions that provide custom search
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -1533,7 +1422,6 @@ func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
return providers
}
// GetURLHandlers returns all extensions that handle custom URLs
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -1547,7 +1435,6 @@ func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
return providers
}
// FindURLHandler finds an extension that can handle the given URL
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -1560,14 +1447,11 @@ func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper
return nil
}
// ExtURLHandleResultWithExtID wraps ExtURLHandleResult with extension ID for gomobile compatibility
type ExtURLHandleResultWithExtID struct {
Result *ExtURLHandleResult
ExtensionID string
}
// HandleURLWithExtension tries to handle a URL with any matching extension
// Returns result with extension ID, or error if no handler found
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
handler := m.FindURLHandler(url)
if handler == nil {
+49 -40
View File
@@ -1,8 +1,11 @@
package gobackend
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -23,9 +26,8 @@ type ExtensionAuthState struct {
RefreshToken string
ExpiresAt time.Time
IsAuthenticated bool
// PKCE support
PKCEVerifier string
PKCEChallenge string
PKCEVerifier string
PKCEChallenge string
}
type PendingAuthRequest struct {
@@ -39,7 +41,6 @@ var (
pendingAuthRequestsMu sync.RWMutex
)
// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter)
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
pendingAuthRequestsMu.RLock()
defer pendingAuthRequestsMu.RUnlock()
@@ -105,8 +106,16 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
Timeout: 30 * time.Second,
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Validate redirect target domain against allowed domains
if req.URL.Scheme != "https" {
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
return fmt.Errorf("redirect blocked: only https is allowed")
}
domain := req.URL.Hostname()
if domain == "" {
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
return fmt.Errorf("redirect blocked: hostname is required")
}
if !ext.Manifest.IsDomainAllowed(domain) {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain}
@@ -115,7 +124,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
}
// Default redirect limit (10)
if len(via) >= 10 {
return http.ErrUseLastResponse
}
@@ -141,35 +149,48 @@ func (e *RedirectBlockedError) Error() string {
// isPrivateIP checks if a hostname resolves to a private/local IP address
func isPrivateIP(host string) bool {
// Block common private network patterns
// This is a simple check - for production, consider DNS resolution
privatePatterns := []string{
"localhost",
"127.",
"10.",
"172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.",
"172.24.", "172.25.", "172.26.", "172.27.",
"172.28.", "172.29.", "172.30.", "172.31.",
"192.168.",
"169.254.",
"::1",
"fc00:",
"fe80:",
hostLower := strings.ToLower(strings.TrimSpace(host))
if hostLower == "" {
return false
}
hostLower := host
for _, pattern := range privatePatterns {
if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern {
if hostLower == "localhost" || strings.HasSuffix(hostLower, ".local") {
return true
}
if ip := net.ParseIP(hostLower); ip != nil {
return isPrivateIPAddr(ip)
}
ips, err := net.LookupIP(hostLower)
if err != nil {
return false
}
for _, ip := range ips {
if isPrivateIPAddr(ip) {
return true
}
}
// Also block .local domains
if len(host) > 6 && host[len(host)-6:] == ".local" {
return false
}
func isPrivateIPAddr(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified() {
return true
}
if !ip.IsGlobalUnicast() {
return true
}
return false
}
@@ -201,18 +222,16 @@ func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings
}
// RegisterAPIs registers all sandboxed APIs to the Goja VM
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
r.vm = vm
// HTTP client (sandboxed to allowed domains)
httpObj := vm.NewObject()
httpObj.Set("get", r.httpGet)
httpObj.Set("post", r.httpPost)
httpObj.Set("put", r.httpPut)
httpObj.Set("delete", r.httpDelete)
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)
vm.Set("http", httpObj)
@@ -222,7 +241,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
storageObj.Set("remove", r.storageRemove)
vm.Set("storage", storageObj)
// Secure Credentials API (encrypted storage for sensitive data)
credentialsObj := vm.NewObject()
credentialsObj.Set("store", r.credentialsStore)
credentialsObj.Set("get", r.credentialsGet)
@@ -237,14 +255,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
authObj.Set("clearAuth", r.authClear)
authObj.Set("isAuthenticated", r.authIsAuthenticated)
authObj.Set("getTokens", r.authGetTokens)
// PKCE support
authObj.Set("generatePKCE", r.authGeneratePKCE)
authObj.Set("getPKCE", r.authGetPKCE)
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
vm.Set("auth", authObj)
// File operations (sandboxed)
fileObj := vm.NewObject()
fileObj.Set("download", r.fileDownload)
fileObj.Set("exists", r.fileExists)
@@ -262,7 +278,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
ffmpegObj.Set("convert", r.ffmpegConvert)
vm.Set("ffmpeg", ffmpegObj)
// Track matching API
matchingObj := vm.NewObject()
matchingObj.Set("compareStrings", r.matchingCompareStrings)
matchingObj.Set("compareDuration", r.matchingCompareDuration)
@@ -279,14 +294,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("hmacSHA1", r.hmacSHA1)
utilsObj.Set("parseJSON", r.parseJSON)
utilsObj.Set("stringifyJSON", r.stringifyJSON)
// Crypto utilities for developers
utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
vm.Set("utils", utilsObj)
// Log object (already set in extension_manager.go, but we can enhance it)
logObj := vm.NewObject()
logObj.Set("debug", r.logDebug)
logObj.Set("info", r.logInfo)
@@ -298,10 +311,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
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("atob", r.atobPolyfill)
+1 -14
View File
@@ -70,13 +70,11 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
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 {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
// Can accept either just auth code or an object with tokens
arg := call.Arguments[0].Export()
extensionAuthStateMu.Lock()
@@ -123,7 +121,6 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true)
}
// authIsAuthenticated checks if extension has valid auth
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -196,7 +193,6 @@ func generatePKCEChallenge(verifier string) string {
}
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
// Default length is 64 characters
length := 64
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
@@ -249,9 +245,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
})
}
// authStartOAuthWithPKCE is a high-level helper that generates PKCE and opens OAuth URL
// config: { authUrl, clientId, redirectUri, scope, extraParams }
// Returns: { success, authUrl, pkce: { verifier, challenge } }
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -269,7 +263,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
})
}
// Required fields
authURL, _ := config["authUrl"].(string)
clientID, _ := config["clientId"].(string)
redirectURI, _ := config["redirectUri"].(string)
@@ -281,11 +274,9 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
})
}
// Optional fields
scope, _ := config["scope"].(string)
extraParams, _ := config["extraParams"].(map[string]interface{})
// Generate PKCE
verifier, err := generatePKCEVerifier(64)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -295,7 +286,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
}
challenge := generatePKCEChallenge(verifier)
// Store PKCE in auth state
extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
@@ -304,10 +294,9 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
}
state.PKCEVerifier = verifier
state.PKCEChallenge = challenge
state.AuthCode = "" // Clear any previous auth code
state.AuthCode = ""
extensionAuthStateMu.Unlock()
// Build OAuth URL with PKCE parameters
parsedURL, err := url.Parse(authURL)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -327,7 +316,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
query.Set("scope", scope)
}
// Add extra params
for k, v := range extraParams {
query.Set(k, fmt.Sprintf("%v", v))
}
@@ -335,7 +323,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
parsedURL.RawQuery = query.Encode()
fullAuthURL := parsedURL.String()
// Store pending auth request for Flutter
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
-12
View File
@@ -64,7 +64,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
command := call.Arguments[0].String()
// Generate unique command ID
ffmpegCommandsMu.Lock()
ffmpegCommandID++
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
@@ -77,7 +76,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
// Wait for completion (with timeout)
timeout := 5 * time.Minute
start := time.Now()
for {
@@ -97,7 +95,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
}
ffmpegCommandsMu.RUnlock()
// Cleanup
ClearFFmpegCommand(cmdID)
return r.vm.ToValue(result)
}
@@ -124,7 +121,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
filePath := call.Arguments[0].String()
// Use Go's built-in audio quality function
quality, err := GetAudioQuality(filePath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -153,7 +149,6 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
inputPath := call.Arguments[0].String()
outputPath := call.Arguments[1].String()
// Get options if provided
options := map[string]interface{}{}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
@@ -161,36 +156,29 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
}
}
// Build FFmpeg command
var cmdParts []string
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
// Audio codec
if codec, ok := options["codec"].(string); ok {
cmdParts = append(cmdParts, "-c:a", codec)
}
// Bitrate
if bitrate, ok := options["bitrate"].(string); ok {
cmdParts = append(cmdParts, "-b:a", bitrate)
}
// Sample rate
if sampleRate, ok := options["sample_rate"].(float64); ok {
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
}
// Channels
if channels, ok := options["channels"].(float64); ok {
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
}
// Overwrite output
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
command := strings.Join(cmdParts, " ")
// Execute via ffmpegExecute
execCall := goja.FunctionCall{
Arguments: []goja.Value{r.vm.ToValue(command)},
}
+28 -8
View File
@@ -15,7 +15,6 @@ import (
// ==================== File API (Sandboxed) ====================
// List of allowed directories for file operations (set by Go backend for download operations)
var (
allowedDownloadDirs []string
allowedDownloadDirsMu sync.RWMutex
@@ -42,18 +41,40 @@ func isPathInAllowedDirs(absPath string) bool {
defer allowedDownloadDirsMu.RUnlock()
for _, allowedDir := range allowedDownloadDirs {
if strings.HasPrefix(absPath, allowedDir) {
if isPathWithinBase(allowedDir, absPath) {
return true
}
}
return false
}
// 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 isPathWithinBase(baseDir, targetPath string) bool {
baseAbs, err := filepath.Abs(baseDir)
if err != nil {
return false
}
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return false
}
rel, err := filepath.Rel(baseAbs, targetAbs)
if err != nil {
return false
}
rel = filepath.Clean(rel)
if rel == "." {
return true
}
prefix := ".." + string(filepath.Separator)
if rel == ".." || strings.HasPrefix(rel, prefix) {
return false
}
return true
}
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
// Check if extension has file permission
if !r.manifest.Permissions.File {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
}
@@ -81,7 +102,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
}
absDataDir, _ := filepath.Abs(r.dataDir)
if !strings.HasPrefix(absPath, absDataDir) {
if !isPathWithinBase(absDataDir, absPath) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
}
@@ -327,7 +348,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
})
}
// Create directory if needed
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
+20 -46
View File
@@ -14,23 +14,30 @@ import (
// ==================== HTTP API (Sandboxed) ====================
// HTTPResponse represents the response from an HTTP request
type HTTPResponse struct {
StatusCode int `json:"statusCode"`
Body string `json:"body"`
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 {
parsed, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
domain := parsed.Hostname()
if parsed.Scheme == "" {
return fmt.Errorf("invalid URL: scheme is required")
}
if parsed.Scheme != "https" {
return fmt.Errorf("network access denied: only https is allowed")
}
domain := parsed.Hostname()
if domain == "" {
return fmt.Errorf("invalid URL: hostname is required")
}
// Block private/local network access (SSRF protection)
if isPrivateIP(domain) {
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
}
@@ -42,7 +49,6 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
return nil
}
// httpGet performs a GET request (sandboxed)
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -76,16 +82,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
})
}
// Set headers - user headers first
for k, v := range headers {
req.Header.Set(k, v)
}
// Only set default User-Agent if not provided by extension
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -101,26 +105,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{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v // Return as array if multiple values
respHeaders[k] = v
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
})
}
// httpPost performs a POST request (sandboxed)
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -137,7 +139,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
})
}
// Get body if provided - support both string and object
var bodyStr string
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
bodyArg := call.Arguments[1].Export()
@@ -145,7 +146,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
// Auto-stringify objects and arrays to JSON
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -154,12 +154,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}
bodyStr = string(jsonBytes)
default:
// Fallback to string conversion
bodyStr = call.Arguments[1].String()
}
}
// Get headers if provided
headers := make(map[string]string)
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
headersObj := call.Arguments[2].Export()
@@ -177,11 +175,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
})
}
// Set headers - user headers first
for k, v := range headers {
req.Header.Set(k, v)
}
// Only set defaults if not provided by extension
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
@@ -189,7 +186,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
req.Header.Set("Content-Type", "application/json")
}
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -205,19 +201,18 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
})
}
// Extract response headers - return all values as arrays for multi-value headers
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v // Return as array if multiple values
respHeaders[k] = v
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
@@ -240,27 +235,22 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
})
}
// Default options
method := "GET"
var bodyStr 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]) {
optionsObj := call.Arguments[1].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
// Get method
if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m)
}
// Get body - support both string and object
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
// Auto-stringify objects and arrays to JSON
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -273,7 +263,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}
}
// Get headers
if h, ok := opts["headers"].(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
@@ -282,7 +271,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}
}
// Create request
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
@@ -295,11 +283,10 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
})
}
// Set headers - user headers first
for k, v := range headers {
req.Header.Set(k, v)
}
// Only set defaults if not provided by extension
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
@@ -307,7 +294,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
req.Header.Set("Content-Type", "application/json")
}
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -323,20 +309,18 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
})
}
// Extract response headers - return all values as arrays for multi-value headers
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
respHeaders[k] = v[0]
} 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{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
@@ -347,7 +331,6 @@ func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
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 {
return r.httpMethodShortcut("DELETE", call)
}
@@ -356,8 +339,6 @@ func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
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 {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -377,9 +358,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
var bodyStr string
headers := make(map[string]string)
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
if method == "DELETE" {
// http.delete(url, headers)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
@@ -389,7 +368,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}
}
} 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]) {
bodyArg := call.Arguments[1].Export()
switch v := bodyArg.(type) {
@@ -418,7 +396,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}
}
// Create request
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
@@ -431,7 +408,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
})
}
// Set headers - user headers first
for k, v := range headers {
req.Header.Set(k, v)
}
@@ -442,7 +418,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
req.Header.Set("Content-Type", "application/json")
}
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -458,7 +433,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
})
}
// Extract response headers
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
+4 -19
View File
@@ -9,7 +9,6 @@ import (
// ==================== Track Matching API ====================
// matchingCompareStrings compares two strings with fuzzy matching
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(0.0)
@@ -22,12 +21,10 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
return r.vm.ToValue(1.0)
}
// Calculate Levenshtein distance-based similarity
similarity := calculateStringSimilarity(str1, str2)
return r.vm.ToValue(similarity)
}
// matchingCompareDuration compares two durations with tolerance
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(false)
@@ -36,8 +33,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
dur1 := int(call.Arguments[0].ToInteger())
dur2 := int(call.Arguments[1].ToInteger())
// Default tolerance: 3 seconds
tolerance := 3000 // milliseconds
tolerance := 3000
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
tolerance = int(call.Arguments[2].ToInteger())
}
@@ -50,7 +46,6 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
return r.vm.ToValue(diff <= tolerance)
}
// matchingNormalizeString normalizes a string for comparison
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
@@ -61,7 +56,6 @@ func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.
return r.vm.ToValue(normalized)
}
// calculateStringSimilarity calculates similarity between two strings (0-1)
func calculateStringSimilarity(s1, s2 string) float64 {
if len(s1) == 0 && len(s2) == 0 {
return 1.0
@@ -70,7 +64,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
return 0.0
}
// Use Levenshtein distance
distance := levenshteinDistance(s1, s2)
maxLen := len(s1)
if len(s2) > maxLen {
@@ -80,7 +73,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
return 1.0 - float64(distance)/float64(maxLen)
}
// levenshteinDistance calculates the Levenshtein distance between two strings
func levenshteinDistance(s1, s2 string) int {
if len(s1) == 0 {
return len(s2)
@@ -89,7 +81,6 @@ func levenshteinDistance(s1, s2 string) int {
return len(s1)
}
// Create matrix
matrix := make([][]int, len(s1)+1)
for i := range matrix {
matrix[i] = make([]int, len(s2)+1)
@@ -99,7 +90,6 @@ func levenshteinDistance(s1, s2 string) int {
matrix[0][j] = j
}
// Fill matrix
for i := 1; i <= len(s1); i++ {
for j := 1; j <= len(s2); j++ {
cost := 1
@@ -107,9 +97,9 @@ func levenshteinDistance(s1, s2 string) int {
cost = 0
}
matrix[i][j] = min(
matrix[i-1][j]+1, // deletion
matrix[i][j-1]+1, // insertion
matrix[i-1][j-1]+cost, // substitution
matrix[i-1][j]+1,
matrix[i][j-1]+1,
matrix[i-1][j-1]+cost,
)
}
}
@@ -117,12 +107,9 @@ func levenshteinDistance(s1, s2 string) int {
return matrix[len(s1)][len(s2)]
}
// normalizeStringForMatching normalizes a string for comparison
func normalizeStringForMatching(s string) string {
// Convert to lowercase
s = strings.ToLower(s)
// Remove common suffixes/prefixes
suffixes := []string{
" (remastered)", " (remaster)", " - remastered", " - remaster",
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
@@ -136,7 +123,6 @@ func normalizeStringForMatching(s string) string {
}
}
// Remove special characters
var result strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
@@ -144,7 +130,6 @@ func normalizeStringForMatching(s string) string {
}
}
// Collapse multiple spaces
s = strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(s)
+1 -41
View File
@@ -25,14 +25,11 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
}
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
return r.createFetchError(err.Error())
}
// Parse options
method := "GET"
var bodyStr string
headers := make(map[string]string)
@@ -40,7 +37,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
optionsObj := call.Arguments[1].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
// Method
if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m)
}
@@ -61,7 +57,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
}
}
// Headers
if h, ok := opts["headers"]; ok && h != nil {
switch hv := h.(type) {
case map[string]interface{}:
@@ -73,7 +68,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
}
}
// Create HTTP request
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
@@ -84,11 +78,9 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return r.createFetchError(err.Error())
}
// Set headers - user headers first
for k, v := range headers {
req.Header.Set(k, v)
}
// Set defaults if not provided
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
}
@@ -96,20 +88,17 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
req.Header.Set("Content-Type", "application/json")
}
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return r.createFetchError(err.Error())
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.createFetchError(err.Error())
}
// Extract response headers
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
@@ -119,7 +108,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
}
}
// Create Response object (browser-compatible)
responseObj := r.vm.NewObject()
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
responseObj.Set("status", resp.StatusCode)
@@ -127,15 +115,12 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
responseObj.Set("headers", respHeaders)
responseObj.Set("url", urlStr)
// Store body for methods
bodyString := string(body)
// text() method - returns body as string
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(bodyString)
})
// json() method - parses body as JSON
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
var result interface{}
if err := json.Unmarshal(body, &result); err != nil {
@@ -145,9 +130,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result)
})
// arrayBuffer() method - returns body as array (simplified)
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
// Return as array of bytes
byteArray := make([]interface{}, len(body))
for i, b := range body {
byteArray[i] = int(b)
@@ -182,7 +165,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
input := call.Arguments[0].String()
decoded, err := base64.StdEncoding.DecodeString(input)
if err != nil {
// Try URL-safe base64
decoded, err = base64.URLEncoding.DecodeString(input)
if err != nil {
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
@@ -203,12 +185,10 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
// TextEncoder constructor
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
encoder := call.This
encoder.Set("encoding", "utf-8")
// encode() method - string to Uint8Array
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue([]byte{})
@@ -216,7 +196,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
input := call.Arguments[0].String()
bytes := []byte(input)
// Return as array (Uint8Array-like)
result := make([]interface{}, len(bytes))
for i, b := range bytes {
result[i] = int(b)
@@ -224,7 +203,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
return vm.ToValue(result)
})
// encodeInto() method
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
// Simplified implementation
if len(call.Arguments) < 2 {
@@ -240,11 +218,9 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
return nil
})
// TextDecoder constructor
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
decoder := call.This
// Get encoding from arguments (default: utf-8)
encoding := "utf-8"
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
encoding = call.Arguments[0].String()
@@ -253,13 +229,11 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
decoder.Set("fatal", false)
decoder.Set("ignoreBOM", false)
// decode() method - Uint8Array to string
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue("")
}
// Handle different input types
input := call.Arguments[0].Export()
var bytes []byte
@@ -279,7 +253,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
}
}
case string:
// Already a string, just return it
return vm.ToValue(v)
default:
return vm.ToValue("")
@@ -292,7 +265,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
})
}
// registerURLClass registers the URL class for URL parsing
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
urlObj := call.This
@@ -304,7 +276,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
urlStr := call.Arguments[0].String()
// Handle relative URLs with base
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
baseStr := call.Arguments[1].String()
baseURL, err := url.Parse(baseStr)
@@ -322,7 +293,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
return nil
}
// Set URL properties
urlObj.Set("href", parsed.String())
urlObj.Set("protocol", parsed.Scheme+":")
urlObj.Set("host", parsed.Host)
@@ -342,10 +312,9 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
password, _ := parsed.User.Password()
urlObj.Set("password", password)
// searchParams object
searchParams := vm.NewObject()
queryValues := parsed.Query()
searchParams := vm.NewObject()
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Null()
@@ -379,12 +348,10 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
urlObj.Set("searchParams", searchParams)
// toString method
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(parsed.String())
})
// toJSON method
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(parsed.String())
})
@@ -392,17 +359,14 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
return nil
})
// URLSearchParams constructor
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
paramsObj := call.This
values := url.Values{}
// Parse initial value if provided
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
init := call.Arguments[0].Export()
switch v := init.(type) {
case string:
// Parse query string
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
values = parsed
case map[string]interface{}:
@@ -468,10 +432,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
// registerJSONGlobal ensures JSON global is properly set up
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
// JSON is already built-in to Goja, but we can enhance it
// This ensures JSON.parse and JSON.stringify work as expected
// The built-in JSON object should already work, but let's verify
// and add any missing functionality if needed
jsonScript := `
if (typeof JSON === 'undefined') {
var JSON = {
+1 -29
View File
@@ -17,12 +17,10 @@ import (
// ==================== Storage API ====================
// getStoragePath returns the path to the extension's storage file
func (r *ExtensionRuntime) getStoragePath() string {
return filepath.Join(r.dataDir, "storage.json")
}
// loadStorage loads the storage data from disk
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
storagePath := r.getStoragePath()
data, err := os.ReadFile(storagePath)
@@ -41,7 +39,6 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
return storage, nil
}
// saveStorage saves the storage data to disk
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
storagePath := r.getStoragePath()
data, err := json.MarshalIndent(storage, "", " ")
@@ -52,7 +49,6 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
return os.WriteFile(storagePath, data, 0644)
}
// storageGet retrieves a value from storage
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Undefined()
@@ -68,7 +64,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
value, exists := storage[key]
if !exists {
// Return default value if provided
if len(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)
}
// storageSet stores a value in storage
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(false)
@@ -103,7 +97,6 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true)
}
// storageRemove removes a value from storage
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
@@ -127,19 +120,14 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
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 {
return filepath.Join(r.dataDir, ".credentials.enc")
}
// getSaltPath returns the path to the extension's encryption salt file
func (r *ExtensionRuntime) getSaltPath() string {
return filepath.Join(r.dataDir, ".cred_salt")
}
// getOrCreateSalt gets existing salt or creates a new random one
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath()
@@ -160,22 +148,17 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
return salt, nil
}
// getEncryptionKey derives an encryption key from extension ID + random salt
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
// Get or create per-installation random salt
salt, err := r.getOrCreateSalt()
if err != nil {
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...)
hash := sha256.Sum256(combined)
return hash[:], nil
}
// loadCredentials loads and decrypts credentials from disk
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
credPath := r.getCredentialsPath()
data, err := os.ReadFile(credPath)
@@ -186,7 +169,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
return nil, err
}
// Decrypt the data
key, err := r.getEncryptionKey()
if err != nil {
return nil, fmt.Errorf("failed to get encryption key: %w", err)
@@ -204,7 +186,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
return creds, nil
}
// saveCredentials encrypts and saves credentials to disk
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
data, err := json.Marshal(creds)
if err != nil {
@@ -221,10 +202,9 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
}
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 {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -260,7 +240,6 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
})
}
// credentialsGet retrieves a decrypted credential
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Undefined()
@@ -276,7 +255,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
value, exists := creds[key]
if !exists {
// Return default value if provided
if len(call.Arguments) > 1 {
return call.Arguments[1]
}
@@ -286,7 +264,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value)
}
// credentialsRemove removes a credential
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
@@ -310,7 +287,6 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
return r.vm.ToValue(true)
}
// credentialsHas checks if a credential exists
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
@@ -327,9 +303,6 @@ func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(exists)
}
// ==================== Crypto Utilities ====================
// encryptAES encrypts data using AES-GCM
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
@@ -350,7 +323,6 @@ func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
return ciphertext, nil
}
// decryptAES decrypts data using AES-GCM
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
+2 -30
View File
@@ -19,7 +19,6 @@ import (
// ==================== Utility Functions ====================
// base64Encode encodes a string to base64
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
@@ -28,7 +27,6 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
}
// base64Decode decodes a base64 string
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
@@ -41,7 +39,6 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded))
}
// md5Hash computes MD5 hash of a string
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
@@ -51,7 +48,6 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:]))
}
// sha256Hash computes SHA256 hash of a string
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
@@ -61,7 +57,6 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
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 {
if len(call.Arguments) < 2 {
return r.vm.ToValue("")
@@ -74,7 +69,6 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
}
// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue("")
@@ -87,9 +81,6 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
}
// 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 {
if len(call.Arguments) < 2 {
return r.vm.ToValue([]byte{})
@@ -142,7 +133,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(jsArray)
}
// parseJSON parses a JSON string
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Undefined()
@@ -158,7 +148,6 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result)
}
// stringifyJSON converts a value to JSON string
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
@@ -174,9 +163,6 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
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 {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -188,7 +174,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
plaintext := call.Arguments[0].String()
keyStr := call.Arguments[1].String()
// Derive 32-byte key from provided key string
keyHash := sha256.Sum256([]byte(keyStr))
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
@@ -205,7 +190,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
})
}
// cryptoDecrypt decrypts a string using AES-GCM (for extension use)
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
@@ -225,14 +209,13 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
})
}
// Derive 32-byte key from provided key string
keyHash := sha256.Sum256([]byte(keyStr))
decrypted, err := decryptAES(ciphertext, keyHash[:])
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
"error": "invalid base64 ciphertext",
})
}
@@ -242,9 +225,8 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
})
}
// cryptoGenerateKey generates a random encryption key
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
length := 32 // Default 256-bit key
length := 32
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok {
length = int(l)
@@ -266,13 +248,10 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
})
}
// randomUserAgent returns a random Chrome User-Agent string
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent())
}
// ==================== Logging Functions ====================
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
@@ -305,8 +284,6 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
return strings.Join(parts, " ")
}
// ==================== Go Backend Wrappers ====================
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
@@ -315,7 +292,6 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
return r.vm.ToValue(sanitizeFilename(input))
}
// RegisterGoBackendAPIs adds more Go backend functions to the VM
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
gobackendObj := vm.Get("gobackend")
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
@@ -325,7 +301,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
obj := gobackendObj.(*goja.Object)
// Expose sanitizeFilename
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue("")
@@ -333,7 +308,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
})
// Expose getAudioQuality
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue(map[string]interface{}{
@@ -356,7 +330,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
})
})
// Expose buildFilename
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return vm.ToValue("")
@@ -373,7 +346,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
})
// Expose getLocalTime - returns device local time info
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
now := time.Now()
_, offsetSeconds := now.Zone()
-19
View File
@@ -9,20 +9,17 @@ import (
"sync"
)
// ExtensionSettingsStore manages settings for all extensions
type ExtensionSettingsStore struct {
mu sync.RWMutex
dataDir string
settings map[string]map[string]interface{} // extensionID -> settings
}
// Global settings store
var (
globalSettingsStore *ExtensionSettingsStore
globalSettingsStoreOnce sync.Once
)
// GetExtensionSettingsStore returns the global settings store
func GetExtensionSettingsStore() *ExtensionSettingsStore {
globalSettingsStoreOnce.Do(func() {
globalSettingsStore = &ExtensionSettingsStore{
@@ -32,7 +29,6 @@ func GetExtensionSettingsStore() *ExtensionSettingsStore {
return globalSettingsStore
}
// SetDataDir sets the data directory for settings storage
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -45,12 +41,10 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
return s.loadAllSettings()
}
// getSettingsPath returns the path to an extension's settings file
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
return filepath.Join(s.dataDir, extensionID, "settings.json")
}
// loadAllSettings loads settings for all extensions from disk
func (s *ExtensionSettingsStore) loadAllSettings() error {
entries, err := os.ReadDir(s.dataDir)
if err != nil {
@@ -75,7 +69,6 @@ func (s *ExtensionSettingsStore) loadAllSettings() error {
return nil
}
// loadSettings loads settings for a specific extension
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
settingsPath := s.getSettingsPath(extensionID)
data, err := os.ReadFile(settingsPath)
@@ -94,7 +87,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
return settings, nil
}
// saveSettings saves settings for a specific extension
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
settingsPath := s.getSettingsPath(extensionID)
@@ -111,8 +103,6 @@ func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[s
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) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -129,7 +119,6 @@ func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, erro
return value, nil
}
// GetAll retrieves all settings for an extension
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -139,7 +128,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
return make(map[string]interface{})
}
// Return a copy
result := make(map[string]interface{})
for k, v := range extSettings {
result[k] = v
@@ -147,7 +135,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
return result
}
// Set stores a setting value for an extension
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -161,18 +148,15 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
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 {
s.mu.Lock()
defer s.mu.Unlock()
s.settings[extensionID] = settings
// Persist to disk
return s.saveSettings(extensionID, settings)
}
// Remove removes a setting for an extension
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -184,11 +168,9 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
delete(extSettings, key)
// Persist to disk
return s.saveSettings(extensionID, extSettings)
}
// RemoveAll removes all settings for an extension
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -203,7 +185,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
return nil
}
// GetAllExtensionSettings returns settings for all extensions as JSON
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
+40 -39
View File
@@ -5,13 +5,13 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sync"
"time"
)
// Extension categories
const (
CategoryMetadata = "metadata"
CategoryDownload = "download"
@@ -20,28 +20,26 @@ const (
CategoryIntegration = "integration"
)
// StoreExtension represents an extension in the store
type StoreExtension struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"`
Category string `json:"category"`
Tags []string `json:"tags,omitempty"`
Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"`
DisplayNameAlt string `json:"displayName,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"`
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"`
Category string `json:"category"`
Tags []string `json:"tags,omitempty"`
Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"`
DisplayNameAlt string `json:"displayName,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"`
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
}
// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict)
func (e *StoreExtension) getDisplayName() string {
if e.DisplayName != "" {
return e.DisplayName
@@ -52,7 +50,6 @@ func (e *StoreExtension) getDisplayName() string {
return e.Name
}
// getDownloadURL returns download URL from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getDownloadURL() string {
if e.DownloadURL != "" {
return e.DownloadURL
@@ -60,7 +57,6 @@ func (e *StoreExtension) getDownloadURL() string {
return e.DownloadURLAlt
}
// getIconURL returns icon URL from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getIconURL() string {
if e.IconURL != "" {
return e.IconURL
@@ -68,7 +64,6 @@ func (e *StoreExtension) getIconURL() string {
return e.IconURLAlt
}
// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getMinAppVersion() string {
if e.MinAppVersion != "" {
return e.MinAppVersion
@@ -76,7 +71,6 @@ func (e *StoreExtension) getMinAppVersion() string {
return e.MinAppVersionAlt
}
// StoreRegistry represents the extension registry
type StoreRegistry struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at"`
@@ -103,7 +97,6 @@ type StoreExtensionResponse struct {
HasUpdate bool `json:"has_update"`
}
// ToResponse converts StoreExtension to normalized response
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
return StoreExtensionResponse{
ID: e.ID,
@@ -122,7 +115,6 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
}
}
// ExtensionStore manages the extension store
type ExtensionStore struct {
registryURL string
cacheDir string
@@ -143,7 +135,6 @@ const (
cacheFileName = "store_cache.json"
)
// InitExtensionStore initializes the extension store
func InitExtensionStore(cacheDir string) *ExtensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
@@ -154,20 +145,17 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
cacheDir: cacheDir,
cacheTTL: cacheTTL,
}
// Try to load from disk cache
extensionStore.loadDiskCache()
}
return extensionStore
}
// GetExtensionStore returns the singleton store instance
func GetExtensionStore() *ExtensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
return extensionStore
}
// loadDiskCache loads cached registry from disk
func (s *ExtensionStore) loadDiskCache() {
if s.cacheDir == "" {
return
@@ -193,7 +181,6 @@ func (s *ExtensionStore) loadDiskCache() {
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
}
// saveDiskCache saves registry to disk cache
func (s *ExtensionStore) saveDiskCache() {
if s.cacheDir == "" || s.cache == nil {
return
@@ -216,23 +203,24 @@ func (s *ExtensionStore) saveDiskCache() {
os.WriteFile(cachePath, data, 0644)
}
// FetchRegistry fetches the extension registry from GitHub
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
// Return cached if valid and not forcing refresh
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
return s.cache, nil
}
if err := requireHTTPSURL(s.registryURL, "registry"); err != nil {
return nil, err
}
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(s.registryURL)
if err != nil {
// Return cached data if available on network error
if s.cache != nil {
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
return s.cache, nil
@@ -267,7 +255,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return &registry, nil
}
// GetExtensionsWithStatus returns extensions with installation status
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(false)
if err != nil {
@@ -299,7 +286,6 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
return result, nil
}
// DownloadExtension downloads an extension package to the specified path
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false)
if err != nil {
@@ -318,6 +304,10 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return fmt.Errorf("extension %s not found in store", extensionID)
}
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
return err
}
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := &http.Client{Timeout: 5 * time.Minute}
@@ -347,7 +337,20 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return nil
}
// GetCategories returns all available categories
func requireHTTPSURL(rawURL string, context string) error {
if rawURL == "" {
return fmt.Errorf("%s URL is empty", context)
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return fmt.Errorf("%s URL is invalid: %s", context, rawURL)
}
if parsed.Scheme != "https" {
return fmt.Errorf("%s URL must use https: %s", context, rawURL)
}
return nil
}
func (s *ExtensionStore) GetCategories() []string {
return []string{
CategoryMetadata,
@@ -358,7 +361,6 @@ func (s *ExtensionStore) GetCategories() []string {
}
}
// SearchExtensions searches extensions by query
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus()
if err != nil {
@@ -404,7 +406,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
return result, nil
}
// ClearCache clears the in-memory and disk cache
func (s *ExtensionStore) ClearCache() {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
+8 -23
View File
@@ -112,7 +112,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
runtime := NewExtensionRuntime(ext)
// Test allowed domains
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
}
@@ -121,7 +120,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
}
// Test blocked domains
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
t.Error("Expected blocked.com to be denied")
}
@@ -139,7 +137,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
File: true, // Enable file permission for test
File: true,
},
},
DataDir: tempDir,
@@ -147,7 +145,6 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
runtime := NewExtensionRuntime(ext)
// Test valid path within sandbox
validPath, err := runtime.validatePath("test.txt")
if err != nil {
t.Errorf("Expected relative path to be valid, got error: %v", err)
@@ -156,13 +153,11 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
t.Error("Expected non-empty path")
}
// Test path traversal attack
_, err = runtime.validatePath("../../../etc/passwd")
if err == nil {
t.Error("Expected path traversal to be blocked")
}
// Test nested path within sandbox (should be allowed)
nestedPath, err := runtime.validatePath("subdir/file.txt")
if err != nil {
t.Errorf("Expected nested path to be valid, got error: %v", err)
@@ -171,26 +166,23 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
t.Error("Expected non-empty nested path")
}
// Test absolute path should be blocked (security fix)
// Use platform-appropriate absolute path
var absPath string
if filepath.IsAbs("C:\\Windows\\System32") {
absPath = "C:\\Windows\\System32\\test.txt" // Windows
absPath = "C:\\Windows\\System32\\test.txt"
} else {
absPath = "/etc/passwd" // Unix
absPath = "/etc/passwd"
}
_, err = runtime.validatePath(absPath)
if err == nil {
t.Error("Expected absolute path to be blocked")
}
// Test that extension without file permission is blocked
extNoFile := &LoadedExtension{
ID: "test-ext-no-file",
Manifest: &ExtensionManifest{
Name: "test-ext-no-file",
Permissions: ExtensionPermissions{
File: false, // No file permission
File: false,
},
},
DataDir: tempDir,
@@ -215,7 +207,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
vm := goja.New()
runtime.RegisterAPIs(vm)
// Test base64 encode/decode
result, err := vm.RunString(`utils.base64Encode("hello")`)
if err != nil {
t.Fatalf("base64Encode failed: %v", err)
@@ -232,7 +223,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
t.Errorf("Expected 'hello', got '%s'", result.String())
}
// Test MD5
result, err = vm.RunString(`utils.md5("hello")`)
if err != nil {
t.Fatalf("md5 failed: %v", err)
@@ -241,7 +231,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
}
// Test JSON parse/stringify
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
if err != nil {
t.Fatalf("stringifyJSON failed: %v", err)
@@ -267,7 +256,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
runtime := NewExtensionRuntime(ext)
// Test that private IPs are blocked (SSRF protection)
privateIPs := []string{
"http://localhost/admin",
"http://127.0.0.1/admin",
@@ -285,7 +273,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
}
}
// Test that allowed public domain still works
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
}
@@ -296,7 +283,6 @@ func TestIsPrivateIP(t *testing.T) {
host string
expected bool
}{
// Private IPs should be blocked
{"localhost", true},
{"127.0.0.1", true},
{"127.0.0.2", true},
@@ -306,18 +292,17 @@ func TestIsPrivateIP(t *testing.T) {
{"172.31.255.255", true},
{"192.168.0.1", true},
{"192.168.255.255", true},
{"169.254.169.254", true}, // AWS metadata
{"169.254.169.254", true},
{"router.local", true},
{"mydevice.local", true},
// Public IPs should be allowed
{"8.8.8.8", false},
{"1.1.1.1", false},
{"api.example.com", false},
{"google.com", false},
{"172.15.0.1", false}, // Just outside 172.16-31 range
{"172.32.0.1", false}, // Just outside 172.16-31 range
{"192.167.0.1", false}, // Not 192.168.x.x
{"172.15.0.1", false},
{"172.32.0.1", false},
{"192.167.0.1", false},
}
for _, tt := range tests {
-13
View File
@@ -10,7 +10,6 @@ import (
"github.com/dop251/goja"
)
// JSExecutionError represents an error during JS execution
type JSExecutionError struct {
Message string
IsTimeout bool
@@ -20,8 +19,6 @@ func (e *JSExecutionError) Error() string {
return e.Message
}
// RunWithTimeout executes JavaScript code with a timeout
// Returns the result value and any error (including timeout)
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
if timeout <= 0 {
timeout = DefaultJSTimeout
@@ -30,22 +27,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Channel to receive result
type result struct {
value goja.Value
err error
}
resultCh := make(chan result, 1)
// Track if we've interrupted
var interrupted bool
var interruptMu sync.Mutex
// Run script in goroutine
go func() {
defer func() {
if r := recover(); r != nil {
// Check if this was our interrupt
interruptMu.Lock()
wasInterrupted := interrupted
interruptMu.Unlock()
@@ -65,22 +58,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
resultCh <- result{val, err}
}()
// Wait for result or timeout
select {
case res := <-resultCh:
return res.value, res.err
case <-ctx.Done():
// Timeout - interrupt the VM
interruptMu.Lock()
interrupted = true
interruptMu.Unlock()
vm.Interrupt("execution timeout")
// Wait a bit for the goroutine to finish
select {
case res := <-resultCh:
// If we got a result after interrupt, it might be the timeout error
if res.err != nil {
return nil, res.err
}
@@ -89,7 +78,6 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
IsTimeout: true,
}
case <-time.After(1 * time.Second):
// Force return timeout error
return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)",
IsTimeout: true,
@@ -109,7 +97,6 @@ func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Dura
return result, err
}
// IsTimeoutError checks if an error is a timeout error
func IsTimeoutError(err error) bool {
if jsErr, ok := err.(*JSExecutionError); ok {
return jsErr.IsTimeout
+2 -2
View File
@@ -1,8 +1,8 @@
module github.com/zarz/spotiflac_android/go_backend
go 1.24.0
go 1.25.0
toolchain go1.24.5
toolchain go1.25.6
require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
+1 -31
View File
@@ -15,11 +15,7 @@ import (
"time"
)
// getRandomUserAgent generates a random Windows Chrome User-Agent string
// Uses modern Chrome format with build and patch numbers
// Windows 11 still reports as "Windows NT 10.0" for compatibility
func getRandomUserAgent() string {
// Chrome version 120-145 (modern range)
chromeVersion := rand.Intn(26) + 120
chromeBuild := rand.Intn(1500) + 6000
chromePatch := rand.Intn(200) + 100
@@ -38,10 +34,9 @@ const (
SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second
Second = time.Second // Exported for use in other files
Second = time.Second
)
// Shared transport with connection pooling to prevent TCP exhaustion
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -85,7 +80,6 @@ func GetDownloadClient() *http.Client {
return downloadClient
}
// CloseIdleConnections closes idle connections in the shared transport
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
}
@@ -117,16 +111,12 @@ 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) {
var lastErr error
delay := config.InitialDelay
requestURL := req.URL.String()
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
// Clone request for retry (body needs to be re-readable)
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
@@ -134,9 +124,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
if err != nil {
lastErr = err
// Check for ISP blocking on network errors
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
// Don't retry if ISP blocking is detected - it won't help
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
}
@@ -149,12 +137,10 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue
}
// Success
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return resp, nil
}
// Handle rate limiting (429)
if resp.StatusCode == 429 {
resp.Body.Close()
retryAfter := getRetryAfterDuration(resp)
@@ -194,7 +180,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
}
}
// Server errors (5xx) - retry
if resp.StatusCode >= 500 {
resp.Body.Close()
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
@@ -206,7 +191,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue
}
// Client errors (4xx except 429) - don't retry
return resp, nil
}
@@ -225,12 +209,10 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
return 60 * time.Second // Default wait time
}
// Try parsing as seconds
if seconds, err := strconv.Atoi(retryAfter); err == nil {
return time.Duration(seconds) * time.Second
}
// Try parsing as HTTP date
if t, err := http.ParseTime(retryAfter); err == nil {
duration := time.Until(t)
if duration > 0 {
@@ -241,8 +223,6 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
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) {
if resp == nil {
return nil, fmt.Errorf("response is nil")
@@ -272,14 +252,12 @@ func ValidateResponse(resp *http.Response) error {
return nil
}
// BuildErrorMessage creates a detailed error message for API failures
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
msg := fmt.Sprintf("API %s failed", apiURL)
if statusCode > 0 {
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
}
if responsePreview != "" {
// Truncate preview if too long
if len(responsePreview) > 100 {
responsePreview = responsePreview[:100] + "..."
}
@@ -298,18 +276,14 @@ func (e *ISPBlockingError) Error() string {
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 {
if err == nil {
return nil
}
// Extract domain from URL
domain := extractDomain(requestURL)
errStr := strings.ToLower(err.Error())
// Check for DNS resolution failure (common ISP blocking method)
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound || dnsErr.IsTemporary {
@@ -321,11 +295,9 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
}
}
// Check for connection refused (ISP firewall blocking)
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Op == "dial" {
// Check for specific syscall errors
var syscallErr syscall.Errno
if errors.As(opErr.Err, &syscallErr) {
switch syscallErr {
@@ -364,7 +336,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
}
}
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
var tlsErr *tls.RecordHeaderError
if errors.As(err, &tlsErr) {
return &ISPBlockingError{
@@ -425,7 +396,6 @@ func extractDomain(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
// Try to extract domain manually
rawURL = strings.TrimPrefix(rawURL, "https://")
rawURL = strings.TrimPrefix(rawURL, "http://")
if idx := strings.Index(rawURL, "/"); idx > 0 {
+1 -8
View File
@@ -35,7 +35,6 @@ func newUTLSTransport() *utlsTransport {
}
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// For non-HTTPS, use standard transport
if req.URL.Scheme != "https" {
return sharedTransport.RoundTrip(req)
}
@@ -44,29 +43,24 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
port := t.getPort(req.URL)
addr := net.JoinHostPort(host, port)
// Dial TCP connection
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
if err != nil {
return nil, err
}
// Create uTLS connection with Chrome fingerprint (supports HTTP/2 ALPN)
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host,
NextProtos: []string{"h2", "http/1.1"}, // Prefer HTTP/2
NextProtos: []string{"h2", "http/1.1"},
}, utls.HelloChrome_Auto)
// Perform TLS handshake
if err := tlsConn.Handshake(); err != nil {
conn.Close()
return nil, err
}
// Check if server supports HTTP/2
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
if negotiatedProto == "h2" {
// Use HTTP/2 transport
h2Transport := &http2.Transport{
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
return tlsConn, nil
@@ -77,7 +71,6 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return h2Transport.RoundTrip(req)
}
// Fallback to HTTP/1.1
transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return tlsConn, nil
+415
View File
@@ -0,0 +1,415 @@
package gobackend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// LibraryScanResult represents metadata from a scanned audio file
type LibraryScanResult struct {
ID string `json:"id"`
TrackName string `json:"trackName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
AlbumArtist string `json:"albumArtist,omitempty"`
FilePath string `json:"filePath"`
CoverPath string `json:"coverPath,omitempty"`
ScannedAt string `json:"scannedAt"`
ISRC string `json:"isrc,omitempty"`
TrackNumber int `json:"trackNumber,omitempty"`
DiscNumber int `json:"discNumber,omitempty"`
Duration int `json:"duration,omitempty"`
ReleaseDate string `json:"releaseDate,omitempty"`
BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,omitempty"`
Genre string `json:"genre,omitempty"`
Format string `json:"format,omitempty"`
}
type LibraryScanProgress struct {
TotalFiles int `json:"total_files"`
ScannedFiles int `json:"scanned_files"`
CurrentFile string `json:"current_file"`
ErrorCount int `json:"error_count"`
ProgressPct float64 `json:"progress_pct"`
IsComplete bool `json:"is_complete"`
}
var (
libraryScanProgress LibraryScanProgress
libraryScanProgressMu sync.RWMutex
libraryScanCancel chan struct{}
libraryScanCancelMu sync.Mutex
libraryCoverCacheDir string
libraryCoverCacheMu sync.RWMutex
)
var supportedAudioFormats = map[string]bool{
".flac": true,
".m4a": true,
".mp3": true,
".opus": true,
".ogg": true,
}
func SetLibraryCoverCacheDir(cacheDir string) {
libraryCoverCacheMu.Lock()
libraryCoverCacheDir = cacheDir
libraryCoverCacheMu.Unlock()
}
func ScanLibraryFolder(folderPath string) (string, error) {
if folderPath == "" {
return "[]", fmt.Errorf("folder path is empty")
}
info, err := os.Stat(folderPath)
if err != nil {
return "[]", fmt.Errorf("folder not found: %w", err)
}
if !info.IsDir() {
return "[]", fmt.Errorf("path is not a folder: %s", folderPath)
}
libraryScanProgressMu.Lock()
libraryScanProgress = LibraryScanProgress{}
libraryScanProgressMu.Unlock()
libraryScanCancelMu.Lock()
if libraryScanCancel != nil {
close(libraryScanCancel)
}
libraryScanCancel = make(chan struct{})
cancelCh := libraryScanCancel
libraryScanCancelMu.Unlock()
var audioFiles []string
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
select {
case <-cancelCh:
return fmt.Errorf("scan cancelled")
default:
}
if !info.IsDir() {
ext := strings.ToLower(filepath.Ext(path))
if supportedAudioFormats[ext] {
audioFiles = append(audioFiles, path)
}
}
return nil
})
if err != nil {
return "[]", err
}
totalFiles := len(audioFiles)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
if totalFiles == 0 {
libraryScanProgressMu.Lock()
libraryScanProgress.IsComplete = true
libraryScanProgressMu.Unlock()
return "[]", nil
}
GoLog("[LibraryScan] Found %d audio files to scan\n", totalFiles)
results := make([]LibraryScanResult, 0, totalFiles)
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
for i, filePath := range audioFiles {
select {
case <-cancelCh:
return "[]", fmt.Errorf("scan cancelled")
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = i + 1
libraryScanProgress.CurrentFile = filepath.Base(filePath)
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
result, err := scanAudioFile(filePath, scanTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
continue
}
results = append(results, *result)
}
libraryScanProgressMu.Lock()
libraryScanProgress.ErrorCount = errorCount
libraryScanProgress.IsComplete = true
libraryScanProgressMu.Unlock()
GoLog("[LibraryScan] Scan complete: %d tracks found, %d errors\n", len(results), errorCount)
jsonBytes, err := json.Marshal(results)
if err != nil {
return "[]", fmt.Errorf("failed to marshal results: %w", err)
}
return string(jsonBytes), nil
}
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
ext := strings.ToLower(filepath.Ext(filePath))
result := &LibraryScanResult{
ID: generateLibraryID(filePath),
FilePath: filePath,
ScannedAt: scanTime,
Format: strings.TrimPrefix(ext, "."),
}
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" && ext != ".m4a" {
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
if err == nil && coverPath != "" {
result.CoverPath = coverPath
}
}
switch ext {
case ".flac":
return scanFLACFile(filePath, result)
case ".m4a":
return scanM4AFile(filePath, result)
case ".mp3":
return scanMP3File(filePath, result)
case ".opus", ".ogg":
return scanOggFile(filePath, result)
default:
return scanFromFilename(filePath, result)
}
}
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
return scanFromFilename(filePath, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.ReleaseDate = metadata.Date
result.Genre = metadata.Genre
quality, err := GetAudioQuality(filePath)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
quality, err := GetM4AQuality(filePath)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
return scanFromFilename(filePath, result)
}
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.Genre = metadata.Genre
if metadata.Date != "" {
result.ReleaseDate = metadata.Date
} else {
result.ReleaseDate = metadata.Year
}
result.ISRC = metadata.ISRC
quality, err := GetMP3Quality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth
result.Duration = quality.Duration
}
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadOggVorbisComments(filePath)
if err != nil {
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.Genre = metadata.Genre
result.ReleaseDate = metadata.Date
quality, err := GetOggQuality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth
result.Duration = quality.Duration
}
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
parts := strings.SplitN(filename, " - ", 2)
if len(parts) == 2 {
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
result.TrackName = parts[1]
result.ArtistName = "Unknown Artist"
} else {
result.ArtistName = parts[0]
result.TrackName = parts[1]
}
} else {
if len(filename) > 3 && isNumeric(filename[:2]) {
title := strings.TrimLeft(filename[2:], " .-")
result.TrackName = title
} else {
result.TrackName = filename
}
result.ArtistName = "Unknown Artist"
}
dir := filepath.Dir(filePath)
result.AlbumName = filepath.Base(dir)
if result.AlbumName == "." || result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
return result, nil
}
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
func generateLibraryID(filePath string) string {
return fmt.Sprintf("lib_%x", hashString(filePath))
}
func hashString(s string) uint32 {
var hash uint32 = 5381
for _, c := range s {
hash = ((hash << 5) + hash) + uint32(c)
}
return hash
}
func GetLibraryScanProgress() string {
libraryScanProgressMu.RLock()
defer libraryScanProgressMu.RUnlock()
jsonBytes, _ := json.Marshal(libraryScanProgress)
return string(jsonBytes)
}
func CancelLibraryScan() {
libraryScanCancelMu.Lock()
defer libraryScanCancelMu.Unlock()
if libraryScanCancel != nil {
close(libraryScanCancel)
libraryScanCancel = nil
}
}
func ReadAudioMetadata(filePath string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
result, err := scanAudioFile(filePath, scanTime)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", fmt.Errorf("failed to marshal result: %w", err)
}
return string(jsonBytes), nil
}
-14
View File
@@ -27,7 +27,6 @@ var (
logBufferOnce sync.Once
)
// GetLogBuffer returns the singleton log buffer instance
func GetLogBuffer() *LogBuffer {
logBufferOnce.Do(func() {
globalLogBuffer = &LogBuffer{
@@ -45,7 +44,6 @@ func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.loggingEnabled = enabled
}
// IsLoggingEnabled returns whether logging is enabled
func (lb *LogBuffer) IsLoggingEnabled() bool {
lb.mu.RLock()
defer lb.mu.RUnlock()
@@ -75,7 +73,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
fmt.Printf("[%s] %s\n", tag, message)
}
// GetAll returns all log entries as JSON
func (lb *LogBuffer) GetAll() string {
lb.mu.RLock()
defer lb.mu.RUnlock()
@@ -99,21 +96,18 @@ func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
return entries, len(lb.entries)
}
// Clear clears all log entries
func (lb *LogBuffer) Clear() {
lb.mu.Lock()
defer lb.mu.Unlock()
lb.entries = lb.entries[:0]
}
// Count returns the number of log entries
func (lb *LogBuffer) Count() int {
lb.mu.RLock()
defer lb.mu.RUnlock()
return len(lb.entries)
}
// Helper functions for logging with different levels
func LogDebug(tag, format string, args ...interface{}) {
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
}
@@ -163,15 +157,10 @@ func GoLog(format string, args ...interface{}) {
GetLogBuffer().Add(level, tag, message)
}
// Exported functions for Flutter
// GetLogs returns all logs as JSON array
func GetLogs() string {
return GetLogBuffer().GetAll()
}
// GetLogsSince returns logs since the given index
// Returns JSON: {"logs": [...], "next_index": N}
func GetLogsSince(index int) string {
entries, nextIndex := GetLogBuffer().getSince(index)
logsJson, _ := json.Marshal(entries)
@@ -179,17 +168,14 @@ func GetLogsSince(index int) string {
return result
}
// ClearLogs clears all logs
func ClearLogs() {
GetLogBuffer().Clear()
}
// GetLogCount returns the number of log entries
func GetLogCount() int {
return GetLogBuffer().Count()
}
// SetLoggingEnabled enables or disables logging from Flutter
func SetLoggingEnabled(enabled bool) {
GetLogBuffer().SetLoggingEnabled(enabled)
}
-39
View File
@@ -238,12 +238,9 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
return diff <= durationToleranceSec
}
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
// Normalize artist name - take first artist before comma/semicolon for better matching
primaryArtist := normalizeArtistName(artistName)
// Check cache first (use original artist name for cache key)
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached
@@ -254,12 +251,10 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
var lyrics *LyricsResponse
var err error
// Helper to check if lyrics result is valid (has lines OR is instrumental)
isValidResult := func(l *LyricsResponse) bool {
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
}
// Try exact match first with primary artist
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB"
@@ -267,7 +262,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return lyrics, nil
}
// Try with full artist name if different from primary
if primaryArtist != artistName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && isValidResult(lyrics) {
@@ -277,7 +271,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
}
}
// Try with simplified track name
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
@@ -288,7 +281,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
}
}
// Search with duration matching (use primary artist for search)
query := primaryArtist + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && isValidResult(lyrics) {
@@ -297,7 +289,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return lyrics, nil
}
// Search with simplified name and duration matching
if simplifiedTrack != trackName {
query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
@@ -393,32 +384,6 @@ func msToLRCTimestamp(ms int64) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
}
// Use convertToLRCWithMetadata for full LRC with headers
// Kept for potential future use
// func convertToLRC(lyrics *LyricsResponse) string {
// if lyrics == nil || len(lyrics.Lines) == 0 {
// return ""
// }
//
// var builder strings.Builder
//
// if lyrics.SyncType == "LINE_SYNCED" {
// for _, line := range lyrics.Lines {
// timestamp := msToLRCTimestamp(line.StartTimeMs)
// builder.WriteString(timestamp)
// builder.WriteString(line.Words)
// builder.WriteString("\n")
// }
// } else {
// for _, line := range lyrics.Lines {
// builder.WriteString(line.Words)
// builder.WriteString("\n")
// }
// }
//
// return builder.String()
// }
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
if lyrics == nil || len(lyrics.Lines) == 0 {
return ""
@@ -480,11 +445,7 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result)
}
// normalizeArtistName extracts the primary artist from multi-artist strings
// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX"
// e.g., "Artist1; Artist2" -> "Artist1"
func normalizeArtistName(name string) string {
// Split by common separators: ", " or "; " or " & " or " feat. " or " ft. "
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
result := name
+33 -398
View File
@@ -238,7 +238,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
return f.Save(filePath)
}
// ReadMetadata reads metadata from a FLAC file
func ReadMetadata(filePath string) (*Metadata, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -336,6 +335,39 @@ func fileExists(path string) bool {
return err == nil
}
func ExtractCoverArt(filePath string) ([]byte, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, meta := range f.Meta {
if meta.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
if pic.PictureType == flacpicture.PictureTypeFrontCover && len(pic.ImageData) > 0 {
return pic.ImageData, nil
}
}
}
for _, meta := range f.Meta {
if meta.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
if len(pic.ImageData) > 0 {
return pic.ImageData, nil
}
}
}
return nil, fmt.Errorf("no cover art found in file")
}
func EmbedLyrics(filePath string, lyrics string) error {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -418,7 +450,6 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
return f.Save(filePath)
}
// ExtractLyrics extracts embedded lyrics from a FLAC file
func ExtractLyrics(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -512,356 +543,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
}
// ========================================
// M4A (MP4/AAC) Metadata Embedding
// ========================================
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
input, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open M4A file: %w", err)
}
defer input.Close()
info, err := input.Stat()
if err != nil {
return fmt.Errorf("failed to stat M4A file: %w", err)
}
fileSize := info.Size()
moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize)
if err != nil {
return fmt.Errorf("failed to find moov atom: %w", err)
}
if !moovFound {
return fmt.Errorf("moov atom not found in M4A file")
}
moovContentStart := moovHeader.offset + moovHeader.headerSize
moovContentSize := moovHeader.size - moovHeader.headerSize
udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize)
if err != nil {
return fmt.Errorf("failed to locate udta atom: %w", err)
}
var metaHeader atomHeader
metaFound := false
if udtaFound {
udtaContentStart := udtaHeader.offset + udtaHeader.headerSize
udtaContentSize := udtaHeader.size - udtaHeader.headerSize
metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize)
if err != nil {
return fmt.Errorf("failed to locate meta atom: %w", err)
}
}
metaAtom := buildMetaAtom(metadata, coverData)
metaSize := int64(len(metaAtom))
var delta int64
var newUdtaSize int64
switch {
case udtaFound && metaFound:
delta = metaSize - metaHeader.size
newUdtaSize = udtaHeader.size + delta
case udtaFound && !metaFound:
delta = metaSize
newUdtaSize = udtaHeader.size + delta
case !udtaFound:
newUdtaSize = int64(8 + len(metaAtom))
delta = newUdtaSize
}
newMoovSize := moovHeader.size + delta
if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) {
return fmt.Errorf("moov atom exceeds 32-bit size after update")
}
if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) {
return fmt.Errorf("udta atom exceeds 32-bit size after update")
}
if !udtaFound && newUdtaSize > int64(^uint32(0)) {
return fmt.Errorf("udta atom exceeds 32-bit size after update")
}
tempPath := filePath + ".tmp"
output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
cleanupTemp := true
defer func() {
_ = output.Close()
if cleanupTemp {
_ = os.Remove(tempPath)
}
}()
switch {
case udtaFound && metaFound:
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(metaAtom); err != nil {
return fmt.Errorf("failed to write meta atom: %w", err)
}
metaEnd := metaHeader.offset + metaHeader.size
if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil {
return err
}
case udtaFound && !metaFound:
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
return err
}
insertPos := udtaHeader.offset + udtaHeader.size
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(metaAtom); err != nil {
return fmt.Errorf("failed to write meta atom: %w", err)
}
if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil {
return err
}
case !udtaFound:
newUdtaAtom := buildUdtaAtom(metaAtom)
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
return err
}
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
return err
}
moovEnd := moovHeader.offset + moovHeader.size
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil {
return err
}
if _, err := output.Write(newUdtaAtom); err != nil {
return fmt.Errorf("failed to write udta atom: %w", err)
}
if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil {
return err
}
}
if err := output.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
_ = input.Close()
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
if err := os.Rename(tempPath, filePath); err != nil {
return fmt.Errorf("failed to move temp file: %w", err)
}
cleanupTemp = false
fmt.Printf("[M4A] Metadata embedded successfully\n")
return nil
}
// buildMetaAtom builds a complete meta atom with ilst containing metadata
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
var ilst []byte
if metadata.Title != "" {
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
}
if metadata.Artist != "" {
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
}
if metadata.Album != "" {
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
}
if metadata.AlbumArtist != "" {
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
}
if metadata.Date != "" {
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
}
if metadata.TrackNumber > 0 {
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
}
if metadata.DiscNumber > 0 {
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
}
if metadata.Lyrics != "" {
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
}
if len(coverData) > 0 {
ilst = append(ilst, buildCoverAtom(coverData)...)
}
ilstSize := 8 + len(ilst)
ilstAtom := make([]byte, 4)
ilstAtom[0] = byte(ilstSize >> 24)
ilstAtom[1] = byte(ilstSize >> 16)
ilstAtom[2] = byte(ilstSize >> 8)
ilstAtom[3] = byte(ilstSize)
ilstAtom = append(ilstAtom, []byte("ilst")...)
ilstAtom = append(ilstAtom, ilst...)
hdlr := []byte{
0, 0, 0, 33, // size = 33
'h', 'd', 'l', 'r',
0, 0, 0, 0, // version + flags
0, 0, 0, 0, // predefined
'm', 'd', 'i', 'r', // handler type
'a', 'p', 'p', 'l', // manufacturer
0, 0, 0, 0, // component flags
0, 0, 0, 0, // component flags mask
0, // null terminator
}
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
metaContent = append(metaContent, ilstAtom...)
metaSize := 8 + len(metaContent)
metaAtom := make([]byte, 4)
metaAtom[0] = byte(metaSize >> 24)
metaAtom[1] = byte(metaSize >> 16)
metaAtom[2] = byte(metaSize >> 8)
metaAtom[3] = byte(metaSize)
metaAtom = append(metaAtom, []byte("meta")...)
metaAtom = append(metaAtom, metaContent...)
return metaAtom
}
func buildTextAtom(name, value string) []byte {
valueBytes := []byte(value)
dataSize := 16 + len(valueBytes)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
dataAtom[1] = byte(dataSize >> 16)
dataAtom[2] = byte(dataSize >> 8)
dataAtom[3] = byte(dataSize)
dataAtom = append(dataAtom, []byte("data")...)
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, valueBytes...)
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte(name)...)
atom = append(atom, dataAtom...)
return atom
}
// buildTrackNumberAtom builds trkn atom
func buildTrackNumberAtom(track, total int) []byte {
dataAtom := []byte{
0, 0, 0, 24, // size
'd', 'a', 't', 'a',
0, 0, 0, 0, // type = implicit
0, 0, 0, 0, // locale
0, 0, // padding
byte(track >> 8), byte(track), // track number
byte(total >> 8), byte(total), // total tracks
0, 0, // padding
}
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte("trkn")...)
atom = append(atom, dataAtom...)
return atom
}
func buildDiscNumberAtom(disc, total int) []byte {
dataAtom := []byte{
0, 0, 0, 22, // size
'd', 'a', 't', 'a',
0, 0, 0, 0, // type = implicit
0, 0, 0, 0, // locale
0, 0, // padding
byte(disc >> 8), byte(disc), // disc number
byte(total >> 8), byte(total), // total discs
}
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte("disk")...)
atom = append(atom, dataAtom...)
return atom
}
// buildCoverAtom builds covr atom with image data
func buildCoverAtom(coverData []byte) []byte {
imageType := byte(13)
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
imageType = 14
}
dataSize := 16 + len(coverData)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
dataAtom[1] = byte(dataSize >> 16)
dataAtom[2] = byte(dataSize >> 8)
dataAtom[3] = byte(dataSize)
dataAtom = append(dataAtom, []byte("data")...)
dataAtom = append(dataAtom, 0, 0, 0, imageType)
dataAtom = append(dataAtom, 0, 0, 0, 0)
dataAtom = append(dataAtom, coverData...)
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
atom[1] = byte(atomSize >> 16)
atom[2] = byte(atomSize >> 8)
atom[3] = byte(atomSize)
atom = append(atom, []byte("covr")...)
atom = append(atom, dataAtom...)
return atom
}
func GetM4AQuality(filePath string) (AudioQuality, error) {
f, err := os.Open(filePath)
if err != nil {
@@ -974,52 +655,6 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6
return atomHeader{}, false, nil
}
func writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error {
if len(typ) != 4 {
return fmt.Errorf("invalid atom type: %s", typ)
}
if headerSize == 16 {
header := make([]byte, 16)
binary.BigEndian.PutUint32(header[0:4], 1)
copy(header[4:8], []byte(typ))
binary.BigEndian.PutUint64(header[8:16], uint64(size))
_, err := w.Write(header)
return err
}
if size > int64(^uint32(0)) {
return fmt.Errorf("atom size exceeds 32-bit for %s", typ)
}
header := make([]byte, 8)
binary.BigEndian.PutUint32(header[0:4], uint32(size))
copy(header[4:8], []byte(typ))
_, err := w.Write(header)
return err
}
func copyRange(dst io.Writer, src *os.File, offset, length int64) error {
if length <= 0 {
return nil
}
if _, err := src.Seek(offset, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek source: %w", err)
}
if _, err := io.CopyN(dst, src, length); err != nil {
return fmt.Errorf("failed to copy data: %w", err)
}
return nil
}
func buildUdtaAtom(metaAtom []byte) []byte {
size := 8 + len(metaAtom)
header := make([]byte, 8)
binary.BigEndian.PutUint32(header[0:4], uint32(size))
copy(header[4:8], []byte("udta"))
return append(header, metaAtom...)
}
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
const chunkSize = 64 * 1024
patternMP4A := []byte("mp4a")
+5 -21
View File
@@ -14,10 +14,9 @@ type TrackIDCacheEntry struct {
}
type TrackIDCache struct {
cache map[string]*TrackIDCacheEntry
mu sync.RWMutex
ttl time.Duration
// Cleanup is triggered on writes at a fixed interval to avoid unbounded growth.
cache map[string]*TrackIDCacheEntry
mu sync.RWMutex
ttl time.Duration
lastCleanup time.Time
cleanupInterval time.Duration
}
@@ -52,7 +51,6 @@ func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
return entry
}
// Lazily delete expired entry.
c.mu.Lock()
entry, exists = c.cache[isrc]
if exists && time.Now().After(entry.ExpiresAt) {
@@ -139,7 +137,6 @@ func (c *TrackIDCache) Size() int {
return len(c.cache)
}
// ParallelDownloadResult holds results from parallel operations
type ParallelDownloadResult struct {
CoverData []byte
LyricsData *LyricsResponse
@@ -164,14 +161,11 @@ func FetchCoverAndLyricsParallel(
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("[Parallel] Starting cover download...")
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
if err != nil {
result.CoverErr = err
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
} else {
result.CoverData = data
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
}
}()
}
@@ -180,20 +174,16 @@ func FetchCoverAndLyricsParallel(
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("[Parallel] Starting lyrics fetch...")
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
result.LyricsErr = err
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = lyrics
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
result.LyricsErr = fmt.Errorf("no lyrics found")
fmt.Println("[Parallel] No lyrics found")
}
}()
}
@@ -206,8 +196,8 @@ type PreWarmCacheRequest struct {
ISRC string
TrackName string
ArtistName string
SpotifyID string // Needed for Amazon (SongLink lookup)
Service string // "tidal", "qobuz", "amazon"
SpotifyID string
Service string
}
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
@@ -215,7 +205,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
return
}
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
cache := GetTrackIDCache()
semaphore := make(chan struct{}, 3)
@@ -244,7 +233,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
}
wg.Wait()
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
}
func preWarmTidalCache(isrc, _, _ string) {
@@ -252,7 +240,6 @@ func preWarmTidalCache(isrc, _, _ string) {
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
GetTrackIDCache().SetTidal(isrc, track.ID)
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
}
}
@@ -261,7 +248,6 @@ func preWarmQobuzCache(isrc string) {
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
GetTrackIDCache().SetQobuz(isrc, track.ID)
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
}
}
@@ -270,7 +256,6 @@ func preWarmAmazonCache(isrc, spotifyID string) {
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon {
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
}
}
@@ -283,7 +268,6 @@ func PreWarmCache(tracksJSON string) error {
func ClearTrackCache() {
GetTrackIDCache().Clear()
fmt.Println("[Cache] Track ID cache cleared")
}
func GetCacheSize() int {
+5 -18
View File
@@ -78,7 +78,6 @@ func GetItemProgress(itemID string) string {
return "{}"
}
// StartItemProgress initializes progress tracking for an item
func StartItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -93,7 +92,6 @@ func StartItemProgress(itemID string) {
}
}
// SetItemBytesTotal sets total bytes for an item
func SetItemBytesTotal(itemID string, total int64) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -103,7 +101,6 @@ func SetItemBytesTotal(itemID string, total int64) {
}
}
// SetItemBytesReceived sets bytes received for an item
func SetItemBytesReceived(itemID string, received int64) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -116,7 +113,6 @@ func SetItemBytesReceived(itemID string, received int64) {
}
}
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -130,7 +126,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
}
}
// CompleteItemProgress marks an item as complete
func CompleteItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -142,7 +137,6 @@ func CompleteItemProgress(itemID string) {
}
}
// SetItemProgress sets progress for an item directly
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -158,7 +152,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
}
}
// SetItemFinalizing marks an item as finalizing (embedding metadata)
func SetItemFinalizing(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -169,7 +162,6 @@ func SetItemFinalizing(itemID string) {
}
}
// RemoveItemProgress removes progress tracking for an item
func RemoveItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -177,7 +169,6 @@ func RemoveItemProgress(itemID string) {
delete(multiProgress.Items, itemID)
}
// ClearAllItemProgress clears all item progress
func ClearAllItemProgress() {
multiMu.Lock()
defer multiMu.Unlock()
@@ -185,7 +176,6 @@ func ClearAllItemProgress() {
multiProgress.Items = make(map[string]*ItemProgress)
}
// setDownloadDir sets the default download directory
func setDownloadDir(path string) error {
downloadDirMu.Lock()
defer downloadDirMu.Unlock()
@@ -193,20 +183,18 @@ func setDownloadDir(path string) error {
return nil
}
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
itemID string
current int64
lastReported int64 // Track last reported bytes for threshold-based updates
startTime time.Time // Track start time for speed calculation
lastTime time.Time // Track last update time for speed calculation
lastBytes int64 // Track bytes at last speed calculation
lastReported int64
startTime time.Time
lastTime time.Time
lastBytes int64
}
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
const progressUpdateThreshold = 64 * 1024
// NewItemProgressWriter creates a new progress writer for a specific item
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
now := time.Now()
return &ItemProgressWriter{
@@ -220,7 +208,6 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
}
}
// Write implements io.Writer with threshold-based progress updates and speed tracking
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
return 0, ErrDownloadCancelled
+27 -119
View File
@@ -52,12 +52,10 @@ func qobuzArtistsMatch(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
}
@@ -112,24 +110,19 @@ func qobuzSplitArtists(artists string) []string {
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 {
wordsA := strings.Fields(a)
wordsB := strings.Fields(b)
// Must have same number of words
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
return false
}
// Sort and compare
sortedA := make([]string, len(wordsA))
sortedB := make([]string, len(wordsB))
copy(sortedA, wordsA)
copy(sortedB, wordsB)
// Simple bubble sort (usually just 2-3 words)
for i := 0; i < len(sortedA)-1; i++ {
for j := i + 1; j < len(sortedA); j++ {
if sortedA[i] > sortedA[j] {
@@ -153,7 +146,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
// Exact match
if normExpected == normFound {
return true
}
@@ -182,8 +174,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true
}
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := qobuzIsLatinScript(expectedTitle)
foundLatin := qobuzIsLatinScript(foundTitle)
if expectedLatin != foundLatin {
@@ -194,9 +184,7 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return false
}
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
func qobuzExtractCoreTitle(title string) string {
// Find first occurrence of ( or [
parenIdx := strings.Index(title, "(")
bracketIdx := strings.Index(title, "[")
dashIdx := strings.Index(title, " - ")
@@ -281,49 +269,28 @@ func qobuzCleanTitle(title string) string {
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 {
for _, r := range s {
// Skip common punctuation and numbers
if r < 128 {
continue
}
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
// Latin Extended-B: U+0180 to U+024F
// Latin Extended Additional: U+1E00 to U+1EFF
// 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)
if (r >= 0x0100 && r <= 0x024F) ||
(r >= 0x1E00 && r <= 0x1EFF) ||
(r >= 0x00C0 && r <= 0x00FF) {
continue
}
// CJK ranges - definitely different script
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
(r >= 0x3040 && r <= 0x309F) || // Hiragana
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
(r >= 0x0600 && r <= 0x06FF) || // Arabic
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
if (r >= 0x4E00 && r <= 0x9FFF) ||
(r >= 0x3040 && r <= 0x309F) ||
(r >= 0x30A0 && r <= 0x30FF) ||
(r >= 0xAC00 && r <= 0xD7AF) ||
(r >= 0x0600 && r <= 0x06FF) ||
(r >= 0x0400 && r <= 0x04FF) {
return false
}
}
return true
}
// 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 {
for _, q := range queries {
if q == query {
@@ -336,7 +303,7 @@ func containsQueryQobuz(queries []string, query string) bool {
func NewQobuzDownloader() *QobuzDownloader {
qobuzDownloaderOnce.Do(func() {
globalQobuzDownloader = &QobuzDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
client: NewHTTPClientWithTimeout(DefaultTimeout),
appID: "798273057",
}
})
@@ -344,7 +311,6 @@ func NewQobuzDownloader() *QobuzDownloader {
}
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
// Qobuz API: /track/get?track_id=XXX
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
@@ -371,15 +337,11 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
return &track, nil
}
// GetAvailableAPIs returns list of available Qobuz APIs
// Uses same APIs as PC version for compatibility
func (q *QobuzDownloader) GetAvailableAPIs() []string {
// Same APIs as PC version (referensi/backend/qobuz.go)
// Primary: dab.yeet.su, Fallback: dabmusic.xyz, qobuz.squid.wtf
encodedAPIs := []string{
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId=
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId=
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=", // qobuz.squid.wtf/api/download-music?track_id=
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==",
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=",
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=",
}
var apis []string
@@ -394,21 +356,19 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
return apis
}
// mapJumoQuality maps Qobuz quality codes to Jumo format
func mapJumoQuality(quality string) int {
switch quality {
case "6":
return 6 // 16-bit FLAC
return 6
case "7":
return 7 // 24-bit 96kHz
return 7
case "27":
return 27 // 24-bit 192kHz
return 27
default:
return 6
}
}
// decodeXOR decodes XOR-encoded response from Jumo API
func decodeXOR(data []byte) string {
text := string(data)
runes := []rune(text)
@@ -420,12 +380,9 @@ func decodeXOR(data []byte) string {
return string(result)
}
// downloadFromJumo gets download URL from Jumo API (fallback)
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
formatID := mapJumoQuality(quality)
region := "US"
// Jumo API endpoint
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
GoLog("[Qobuz] Trying Jumo API fallback...\n")
@@ -452,17 +409,13 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
}
var result map[string]any
// Try parsing as plain JSON first
if err := json.Unmarshal(body, &result); err != nil {
// Try XOR decoding
decoded := decodeXOR(body)
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err)
}
}
// Check for URL in various response formats
if urlVal, ok := result["url"].(string); ok && urlVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully\n")
return urlVal, nil
@@ -511,7 +464,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, err
}
// Find exact ISRC match
for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc {
return &result.Tracks.Items[i], nil
@@ -525,7 +477,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
@@ -558,7 +509,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
// Find ISRC matches
var isrcMatches []*QobuzTrack
for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc {
@@ -612,35 +562,26 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
}
// Now includes romaji conversion for Japanese text (same as Tidal)
// Also includes title verification to prevent wrong song downloads
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
// Try multiple search strategies (same as Tidal/PC version)
queries := []string{}
// Strategy 1: Artist + Track name
if artistName != "" && trackName != "" {
queries = append(queries, artistName+" "+trackName)
}
// Strategy 2: Track name only
if trackName != "" {
queries = append(queries, trackName)
}
// Strategy 3: Romaji versions if Japanese detected
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
// Convert to romaji (hiragana/katakana only, kanji stays)
romajiTrack := JapaneseToRomaji(trackName)
romajiArtist := JapaneseToRomaji(artistName)
// Clean and remove ALL non-ASCII characters (including kanji)
cleanRomajiTrack := CleanToASCII(romajiTrack)
cleanRomajiArtist := CleanToASCII(romajiArtist)
// Artist + Track romaji (cleaned to ASCII only)
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
if !containsQueryQobuz(queries, romajiQuery) {
@@ -649,7 +590,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
}
}
// Track romaji only (cleaned)
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
if !containsQueryQobuz(queries, cleanRomajiTrack) {
queries = append(queries, cleanRomajiTrack)
@@ -657,7 +597,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
}
}
// Strategy 4: Artist only as last resort
if artistName != "" {
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
@@ -716,7 +655,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
}
// Filter by title match first (NEW - like Tidal)
var titleMatches []*QobuzTrack
for i := range allTracks {
track := &allTracks[i]
@@ -727,7 +665,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
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
if len(titleMatches) == 0 {
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
@@ -736,7 +673,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
}
}
// If duration verification is requested
if expectedDurationSec > 0 {
var durationMatches []*QobuzTrack
for _, track := range tracksToCheck {
@@ -765,7 +701,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
}
// No duration verification, return best quality from title matches
for _, track := range tracksToCheck {
if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n",
@@ -783,7 +718,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
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 {
apiURL string
downloadURL string
@@ -801,7 +735,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
resultChan := make(chan qobuzAPIResult, len(apis))
startTime := time.Now()
// Start all requests in parallel
for _, apiURL := range apis {
go func(api string) {
reqStart := time.Now()
@@ -834,13 +767,11 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
return
}
// Check if response is HTML (error page)
if len(body) > 0 && body[0] == '<' {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
return
}
// Check for error in JSON response
var errorResp struct {
Error string `json:"error"`
}
@@ -866,7 +797,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
}(apiURL)
}
// Collect results - return first success
var errors []string
for i := 0; i < len(apis); i++ {
@@ -874,7 +804,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
if result.err == nil {
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) {
for j := 0; j < remaining; j++ {
<-resultChan
@@ -906,14 +835,12 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
return downloadURL, nil
}
// All standard APIs failed, try Jumo as fallback
GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n")
jumoURL, jumoErr := q.downloadFromJumo(trackID, quality)
if jumoErr == nil {
return jumoURL, nil
}
// If quality is 27 (hi-res), try fallback to lower quality
if quality == "27" {
GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n")
jumoURL, jumoErr = q.downloadFromJumo(trackID, "7")
@@ -933,11 +860,9 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
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 {
ctx := context.Background()
// Initialize item progress (required for all downloads)
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
@@ -987,7 +912,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
written, err = io.Copy(bufWriter, resp.Body)
}
// Flush buffer before checking for errors
flushErr := bufWriter.Flush()
closeErr := out.Close()
@@ -1007,7 +931,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
@@ -1055,11 +978,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
}
}
// OPTIMIZATION: Check cache first for track ID
if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
// For Qobuz we need to search again to get full track info, but we can use the ID
track, err = downloader.SearchTrackByISRC(req.ISRC)
if err != nil {
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
@@ -1068,11 +989,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
}
}
// Strategy 1: Search by ISRC with duration verification
if track == nil && req.ISRC != "" {
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
// Verify artist AND title
if track != nil {
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
@@ -1086,10 +1005,8 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
}
}
// Strategy 2: Search by metadata with duration verification (includes title verification)
if track == nil {
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) {
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
@@ -1105,7 +1022,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
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)
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
@@ -1126,22 +1042,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
// Map quality from Tidal format to Qobuz format
// 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
qobuzQuality := "27"
switch req.Quality {
case "LOSSLESS":
qobuzQuality = "6" // 16-bit FLAC
qobuzQuality = "6"
case "HI_RES":
qobuzQuality = "7" // 24-bit 96kHz
qobuzQuality = "7"
case "HI_RES_LOSSLESS":
qobuzQuality = "27" // 24-bit 192kHz
qobuzQuality = "27"
}
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
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)
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
@@ -1149,7 +1062,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
@@ -1165,7 +1077,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return QobuzDownloadResult{}, ErrDownloadCancelled
@@ -1173,7 +1084,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Wait for parallel operations to complete
<-parallelDone
if req.ItemID != "" {
@@ -1186,7 +1096,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
albumName = req.AlbumName
}
// Use track number from request if available, otherwise from Qobuz API
actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 {
actualTrackNumber = track.TrackNumber
@@ -1196,15 +1105,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Title: track.Title,
Artist: track.Performer.Name,
Album: albumName,
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
AlbumArtist: req.AlbumArtist,
Date: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
DiscNumber: req.DiscNumber,
ISRC: track.ISRC,
Genre: req.Genre, // From Deezer album metadata
Label: req.Label, // From Deezer album metadata
Copyright: req.Copyright, // From Deezer album metadata
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
@@ -1244,7 +1153,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
}
// Add to ISRC index for fast duplicate checking
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
return QobuzDownloadResult{
@@ -1256,7 +1164,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
DiscNumber: req.DiscNumber,
ISRC: track.ISRC,
}, nil
}
+70 -36
View File
@@ -43,33 +43,6 @@ func NewSongLinkClient() *SongLinkClient {
}
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
if spotifyTrackID == "" {
return nil, fmt.Errorf("spotify track ID is empty")
}
// Try SongLink first
availability, err := s.checkTrackAvailabilitySongLink(spotifyTrackID)
if err != nil {
// Fallback to IDHS if SongLink fails
LogWarn("SongLink", "SongLink failed, trying IDHS fallback: %v", err)
idhsClient := NewIDHSClient()
availability, err = idhsClient.GetAvailabilityFromSpotify(spotifyTrackID)
if err != nil {
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
}
LogInfo("SongLink", "IDHS fallback successful for %s", spotifyTrackID)
}
// Check Qobuz availability separately via ISRC
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
return availability, nil
}
// checkTrackAvailabilitySongLink is the original SongLink implementation
func (s *SongLinkClient) checkTrackAvailabilitySongLink(spotifyTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
@@ -227,10 +200,8 @@ type AlbumAvailability struct {
}
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
// Use global rate limiter
songLinkRateLimiter.WaitForSlot()
// Build API URL for album
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
@@ -301,10 +272,8 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return nil, fmt.Errorf("deezer track ID is empty")
}
// Try SongLink first
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
if err != nil {
// Fallback to IDHS if SongLink fails
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
idhsClient := NewIDHSClient()
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
@@ -338,7 +307,6 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
}
defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
}
@@ -407,11 +375,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
return nil, fmt.Errorf("%s ID is empty", platform)
}
// Use global rate limiter
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",
url.QueryEscape(platform),
url.QueryEscape(entityType),
@@ -429,7 +394,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
}
defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
}
@@ -535,3 +499,73 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
return availability.AmazonURL, nil
}
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(inputURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := DefaultRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 400 || resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on SongLink")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("SongLink rate limit exceeded")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
EntityID string `json:"entityUniqueId"`
} `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
availability := &TrackAvailability{}
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
return availability, nil
}
+10 -15
View File
@@ -63,10 +63,8 @@ var (
credentialsMu sync.RWMutex
)
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
// SetSpotifyCredentials sets custom Spotify API credentials
func SetSpotifyCredentials(clientID, clientSecret string) {
credentialsMu.Lock()
defer credentialsMu.Unlock()
@@ -89,7 +87,6 @@ func HasSpotifyCredentials() bool {
return false
}
// getCredentials returns the current credentials or error if not configured
func getCredentials() (string, string, error) {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
@@ -143,7 +140,7 @@ type TrackMetadata struct {
DiscNumber int `json:"disc_number,omitempty"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
AlbumType string `json:"album_type,omitempty"`
}
type AlbumTrackMetadata struct {
@@ -212,7 +209,7 @@ type ArtistAlbumMetadata struct {
ReleaseDate string `json:"release_date"`
TotalTracks int `json:"total_tracks"`
Images string `json:"images"`
AlbumType string `json:"album_type"` // album, single, compilation
AlbumType string `json:"album_type"`
Artists string `json:"artists"`
}
@@ -534,7 +531,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
albumImage := firstImageURL(data.Images)
// Get first artist ID
var firstArtistId string
if len(data.Artists) > 0 {
firstArtistId = data.Artists[0].ID
@@ -567,7 +563,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
// Collect track IDs for parallel ISRC fetching
trackIDs := make([]string, len(allTrackItems))
for i, item := range allTrackItems {
trackIDs[i] = item.ID
@@ -939,14 +934,14 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
defer c.rngMu.Unlock()
macMajor := c.rng.Intn(4) + 11
macMinor := c.rng.Intn(5) + 4 // 4-8
webkitMajor := c.rng.Intn(7) + 530 // 530-536
webkitMinor := c.rng.Intn(7) + 30 // 30-36
chromeMajor := c.rng.Intn(25) + 80 // 80-104
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
chromePatch := c.rng.Intn(65) + 60 // 60-124
safariMajor := c.rng.Intn(7) + 530 // 530-536
safariMinor := c.rng.Intn(6) + 30 // 30-35
macMinor := c.rng.Intn(5) + 4
webkitMajor := c.rng.Intn(7) + 530
webkitMinor := c.rng.Intn(7) + 30
chromeMajor := c.rng.Intn(25) + 80
chromeBuild := c.rng.Intn(1500) + 3000
chromePatch := c.rng.Intn(65) + 60
safariMajor := c.rng.Intn(7) + 530
safariMinor := c.rng.Intn(6) + 30
return fmt.Sprintf(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
+112 -29
View File
@@ -119,19 +119,18 @@ func NewTidalDownloader() *TidalDownloader {
return globalTidalDownloader
}
// GetAvailableAPIs returns list of available Tidal APIs
func (t *TidalDownloader) GetAvailableAPIs() []string {
encodedAPIs := []string{
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority)
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
}
var apis []string
@@ -251,7 +250,6 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
return trackID, nil
}
// GetTrackInfoByID gets track info by Tidal track ID
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
token, err := t.GetAccessToken()
if err != nil {
@@ -319,7 +317,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, err
}
// Find exact ISRC match
for i := range result.Items {
if result.Items[i].ISRC == isrc {
return &result.Items[i], nil
@@ -343,7 +340,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
// Build search queries - multiple strategies (same as PC version)
queries := []string{}
// Strategy 1: Artist + Track name (original)
if artistName != "" && trackName != "" {
queries = append(queries, artistName+" "+trackName)
}
@@ -586,7 +582,6 @@ type tidalAPIResult struct {
duration time.Duration
}
// Returns the first successful result (supports both v1 and v2 API formats)
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
if len(apis) == 0 {
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
@@ -797,7 +792,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return "", initURL, mediaURLs, nil
}
// DownloadFile downloads a file from URL with progress tracking
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
@@ -970,7 +964,15 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return nil
}
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
// For DASH format, determine correct M4A path
// If outputPath already ends with .m4a, use it directly
// Otherwise, convert .flac to .m4a
var m4aPath string
if strings.HasSuffix(outputPath, ".m4a") {
m4aPath = outputPath
} else {
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
}
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
out, err := os.Create(m4aPath)
@@ -1096,6 +1098,7 @@ type TidalDownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string // LRC content for embedding in converted files
}
func artistsMatch(spotifyArtist, tidalArtist string) bool {
@@ -1106,7 +1109,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return true
}
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
return true
}
@@ -1165,7 +1167,6 @@ func sameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a)
wordsB := strings.Fields(b)
// Must have same number of words
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
return false
}
@@ -1198,7 +1199,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
// Exact match
if normExpected == normFound {
return true
}
@@ -1207,7 +1207,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true
}
// Clean both titles and compare
cleanExpected := cleanTitle(normExpected)
cleanFound := cleanTitle(normFound)
@@ -1221,7 +1220,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
}
}
// Extract core title (before any parentheses/brackets)
coreExpected := extractCoreTitle(normExpected)
coreFound := extractCoreTitle(normFound)
@@ -1229,7 +1227,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true
}
// Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := isLatinScript(expectedTitle)
foundLatin := isLatinScript(foundTitle)
if expectedLatin != foundLatin {
@@ -1502,6 +1499,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
}
quality := req.Quality
if quality == "" {
quality = "LOSSLESS"
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -1510,15 +1512,26 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
"year": extractYear(req.ReleaseDate),
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
var outputPath string
var m4aPath string
if quality == "HIGH" {
filename = sanitizeFilename(filename) + ".m4a"
outputPath = filepath.Join(req.OutputDir, filename)
m4aPath = outputPath
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
}
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
if quality != "HIGH" {
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
}
}
tmpPath := outputPath + ".m4a.tmp"
@@ -1527,10 +1540,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
os.Remove(tmpPath)
}
quality := req.Quality
if quality == "" {
quality = "LOSSLESS"
}
GoLog("[Tidal] Using quality: %s\n", quality)
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
@@ -1593,7 +1602,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
}
// Use track number from request if available, otherwise from Tidal API
actualTrackNumber := req.TrackNumber
actualDiscNumber := req.DiscNumber
if actualTrackNumber == 0 {
@@ -1656,15 +1664,52 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
fmt.Println("[Tidal] No lyrics available from parallel fetch")
}
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
if quality == "HIGH" {
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
}
}
}
} else {
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
}
}
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
bitDepth := downloadInfo.BitDepth
sampleRate := downloadInfo.SampleRate
lyricsLRC := ""
if quality == "HIGH" {
bitDepth = 0
sampleRate = 44100
if parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "embed" || lyricsMode == "both" {
lyricsLRC = parallelResult.LyricsLRC
}
}
}
return TidalDownloadResult{
FilePath: actualOutputPath,
BitDepth: downloadInfo.BitDepth,
SampleRate: downloadInfo.SampleRate,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: track.Title,
Artist: track.Artist.Name,
Album: track.Album.Title,
@@ -1672,5 +1717,43 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
TrackNumber: actualTrackNumber,
DiscNumber: actualDiscNumber,
ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil
}
func parseTidalURL(input string) (string, string, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return "", "", fmt.Errorf("empty URL")
}
parsed, err := url.Parse(trimmed)
if err != nil {
return "", "", err
}
if parsed.Host != "tidal.com" && parsed.Host != "listen.tidal.com" && parsed.Host != "www.tidal.com" {
return "", "", fmt.Errorf("not a Tidal URL")
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
// Handle /browse/track/123 format
if len(parts) > 0 && parts[0] == "browse" {
parts = parts[1:]
}
if len(parts) < 2 {
return "", "", fmt.Errorf("invalid Tidal URL format")
}
resourceType := parts[0]
resourceID := parts[1]
switch resourceType {
case "track", "album", "artist", "playlist":
return resourceType, resourceID, nil
default:
return "", "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
}
}
+43
View File
@@ -242,6 +242,20 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "parseTidalUrl":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendParseTidalURLExport(url, &error)
if let error = error { throw error }
return response
case "convertTidalToSpotifyDeezer":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendConvertTidalToSpotifyDeezer(url, &error)
if let error = error { throw error }
return response
case "searchDeezerByISRC":
let args = call.arguments as! [String: Any]
let isrc = args["isrc"] as! String
@@ -687,6 +701,35 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Local Library Scanning
case "setLibraryCoverCacheDir":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
GobackendSetLibraryCoverCacheDirJSON(cacheDir)
return nil
case "scanLibraryFolder":
let args = call.arguments as! [String: Any]
let folderPath = args["folder_path"] as! String
let response = GobackendScanLibraryFolderJSON(folderPath, &error)
if let error = error { throw error }
return response
case "getLibraryScanProgress":
let response = GobackendGetLibraryScanProgressJSON()
return response
case "cancelLibraryScan":
GobackendCancelLibraryScanJSON()
return nil
case "readAudioMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendReadAudioMetadataJSON(filePath, &error)
if let error = error { throw error }
return response
default:
throw NSError(
domain: "SpotiFLAC",
+25 -1
View File
@@ -67,7 +67,7 @@
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<false/>
</dict>
<!-- File Sharing - Allow access via Files app -->
@@ -81,5 +81,29 @@
<!-- Photo Library (for cover art if needed) -->
<key>NSPhotoLibraryUsageDescription</key>
<string>SpotiFLAC needs access to save album artwork</string>
<!-- URL Schemes for deep linking -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>com.zarz.spotiflac</string>
<key>CFBundleURLSchemes</key>
<array>
<string>spotiflac</string>
</array>
</dict>
</array>
<!-- Associated Domains for Universal Links -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>spotify</string>
<string>deezer</string>
<string>tidal</string>
<string>youtube-music</string>
</array>
</dict>
</plist>
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.3.0';
static const String buildNumber = '67';
static const String version = '3.4.0';
static const String buildNumber = '72';
static const String fullVersion = '$version+$buildNumber';
+421 -1
View File
@@ -142,7 +142,13 @@ abstract class AppLocalizations {
/// **'Home'**
String get navHome;
/// Bottom navigation - History tab
/// Bottom navigation - Library tab
///
/// In en, this message translates to:
/// **'Library'**
String get navLibrary;
/// Bottom navigation - History tab (legacy)
///
/// In en, this message translates to:
/// **'History'**
@@ -1312,6 +1318,12 @@ abstract class AppLocalizations {
/// **'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'**
String get setupIosEmptyFolderWarning;
/// Error when user selects iCloud Drive on iOS
///
/// In en, this message translates to:
/// **'iCloud Drive is not supported. Please use the app Documents folder.'**
String get setupIcloudNotSupported;
/// App tagline in setup
///
/// In en, this message translates to:
@@ -1756,6 +1768,12 @@ abstract class AppLocalizations {
/// **'\"{trackName}\" already downloaded'**
String snackbarAlreadyDownloaded(String trackName);
/// Snackbar - track already exists in local library
///
/// In en, this message translates to:
/// **'\"{trackName}\" already exists in your library'**
String snackbarAlreadyInLibrary(String trackName);
/// Snackbar - history deleted
///
/// In en, this message translates to:
@@ -3646,6 +3664,66 @@ abstract class AppLocalizations {
/// **'Are you sure you want to clear all downloads?'**
String get queueClearAllMessage;
/// Button - export failed downloads to TXT
///
/// In en, this message translates to:
/// **'Export'**
String get queueExportFailed;
/// Success message after exporting failed downloads
///
/// In en, this message translates to:
/// **'Failed downloads exported to TXT file'**
String get queueExportFailedSuccess;
/// Action to clear failed downloads after export
///
/// In en, this message translates to:
/// **'Clear Failed'**
String get queueExportFailedClear;
/// Error message when export fails
///
/// In en, this message translates to:
/// **'Failed to export downloads'**
String get queueExportFailedError;
/// Setting toggle for auto-export
///
/// In en, this message translates to:
/// **'Auto-export failed downloads'**
String get settingsAutoExportFailed;
/// Subtitle for auto-export setting
///
/// In en, this message translates to:
/// **'Save failed downloads to TXT file automatically'**
String get settingsAutoExportFailedSubtitle;
/// Setting for network type preference
///
/// In en, this message translates to:
/// **'Download Network'**
String get settingsDownloadNetwork;
/// Network option - use any connection
///
/// In en, this message translates to:
/// **'WiFi + Mobile Data'**
String get settingsDownloadNetworkAny;
/// Network option - only use WiFi
///
/// In en, this message translates to:
/// **'WiFi Only'**
String get settingsDownloadNetworkWifiOnly;
/// Subtitle explaining network preference
///
/// In en, this message translates to:
/// **'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'**
String get settingsDownloadNetworkSubtitle;
/// Empty queue state title
///
/// In en, this message translates to:
@@ -3999,6 +4077,348 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'All Files Access disabled. The app will use limited storage access.'**
String get allFilesAccessDisabledMessage;
/// Settings menu item - local library
///
/// In en, this message translates to:
/// **'Local Library'**
String get settingsLocalLibrary;
/// Subtitle for local library settings
///
/// In en, this message translates to:
/// **'Scan music & detect duplicates'**
String get settingsLocalLibrarySubtitle;
/// Library settings page title
///
/// In en, this message translates to:
/// **'Local Library'**
String get libraryTitle;
/// Section header for library status
///
/// In en, this message translates to:
/// **'Library Status'**
String get libraryStatus;
/// Section header for scan settings
///
/// In en, this message translates to:
/// **'Scan Settings'**
String get libraryScanSettings;
/// Toggle to enable library scanning
///
/// In en, this message translates to:
/// **'Enable Local Library'**
String get libraryEnableLocalLibrary;
/// Subtitle for enable toggle
///
/// In en, this message translates to:
/// **'Scan and track your existing music'**
String get libraryEnableLocalLibrarySubtitle;
/// Folder selection setting
///
/// In en, this message translates to:
/// **'Library Folder'**
String get libraryFolder;
/// Placeholder when no folder selected
///
/// In en, this message translates to:
/// **'Tap to select folder'**
String get libraryFolderHint;
/// Toggle for duplicate indicator in search
///
/// In en, this message translates to:
/// **'Show Duplicate Indicator'**
String get libraryShowDuplicateIndicator;
/// Subtitle for duplicate indicator toggle
///
/// In en, this message translates to:
/// **'Show when searching for existing tracks'**
String get libraryShowDuplicateIndicatorSubtitle;
/// Section header for library actions
///
/// In en, this message translates to:
/// **'Actions'**
String get libraryActions;
/// Button to start library scan
///
/// In en, this message translates to:
/// **'Scan Library'**
String get libraryScan;
/// Subtitle for scan button
///
/// In en, this message translates to:
/// **'Scan for audio files'**
String get libraryScanSubtitle;
/// Message when trying to scan without folder
///
/// In en, this message translates to:
/// **'Select a folder first'**
String get libraryScanSelectFolderFirst;
/// Button to remove entries for missing files
///
/// In en, this message translates to:
/// **'Cleanup Missing Files'**
String get libraryCleanupMissingFiles;
/// Subtitle for cleanup button
///
/// In en, this message translates to:
/// **'Remove entries for files that no longer exist'**
String get libraryCleanupMissingFilesSubtitle;
/// Button to clear all library entries
///
/// In en, this message translates to:
/// **'Clear Library'**
String get libraryClear;
/// Subtitle for clear button
///
/// In en, this message translates to:
/// **'Remove all scanned tracks'**
String get libraryClearSubtitle;
/// Dialog title for clear confirmation
///
/// In en, this message translates to:
/// **'Clear Library'**
String get libraryClearConfirmTitle;
/// Dialog message for clear confirmation
///
/// In en, this message translates to:
/// **'This will remove all scanned tracks from your library. Your actual music files will not be deleted.'**
String get libraryClearConfirmMessage;
/// Section header for about info
///
/// In en, this message translates to:
/// **'About Local Library'**
String get libraryAbout;
/// Description of local library feature
///
/// In en, this message translates to:
/// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'**
String get libraryAboutDescription;
/// Track count in library
///
/// In en, this message translates to:
/// **'{count} tracks'**
String libraryTracksCount(int count);
/// Last scan time display
///
/// In en, this message translates to:
/// **'Last scanned: {time}'**
String libraryLastScanned(String time);
/// Shown when library has never been scanned
///
/// In en, this message translates to:
/// **'Never'**
String get libraryLastScannedNever;
/// Status during scan
///
/// In en, this message translates to:
/// **'Scanning...'**
String get libraryScanning;
/// Scan progress display
///
/// In en, this message translates to:
/// **'{progress}% of {total} files'**
String libraryScanProgress(String progress, int total);
/// Badge shown on tracks that exist in local library
///
/// In en, this message translates to:
/// **'In Library'**
String get libraryInLibrary;
/// Snackbar after cleanup
///
/// In en, this message translates to:
/// **'Removed {count} missing files from library'**
String libraryRemovedMissingFiles(int count);
/// Snackbar after clearing library
///
/// In en, this message translates to:
/// **'Library cleared'**
String get libraryCleared;
/// Dialog title for storage permission
///
/// In en, this message translates to:
/// **'Storage Access Required'**
String get libraryStorageAccessRequired;
/// Dialog message for storage permission
///
/// In en, this message translates to:
/// **'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.'**
String get libraryStorageAccessMessage;
/// Error when folder doesn't exist
///
/// In en, this message translates to:
/// **'Selected folder does not exist'**
String get libraryFolderNotExist;
/// Badge for tracks downloaded via SpotiFLAC
///
/// In en, this message translates to:
/// **'Downloaded'**
String get librarySourceDownloaded;
/// Badge for tracks from local library scan
///
/// In en, this message translates to:
/// **'Local'**
String get librarySourceLocal;
/// Filter chip - show all library items
///
/// In en, this message translates to:
/// **'All'**
String get libraryFilterAll;
/// Filter chip - show only downloaded items
///
/// In en, this message translates to:
/// **'Downloaded'**
String get libraryFilterDownloaded;
/// Filter chip - show only local library items
///
/// In en, this message translates to:
/// **'Local'**
String get libraryFilterLocal;
/// Filter bottom sheet title
///
/// In en, this message translates to:
/// **'Filters'**
String get libraryFilterTitle;
/// Reset all filters button
///
/// In en, this message translates to:
/// **'Reset'**
String get libraryFilterReset;
/// Apply filters button
///
/// In en, this message translates to:
/// **'Apply'**
String get libraryFilterApply;
/// Filter section - source type
///
/// In en, this message translates to:
/// **'Source'**
String get libraryFilterSource;
/// Filter section - audio quality
///
/// In en, this message translates to:
/// **'Quality'**
String get libraryFilterQuality;
/// Filter option - high resolution audio
///
/// In en, this message translates to:
/// **'Hi-Res (24bit)'**
String get libraryFilterQualityHiRes;
/// Filter option - CD quality audio
///
/// In en, this message translates to:
/// **'CD (16bit)'**
String get libraryFilterQualityCD;
/// Filter option - lossy compressed audio
///
/// In en, this message translates to:
/// **'Lossy'**
String get libraryFilterQualityLossy;
/// Filter section - file format
///
/// In en, this message translates to:
/// **'Format'**
String get libraryFilterFormat;
/// Filter section - date range
///
/// In en, this message translates to:
/// **'Date Added'**
String get libraryFilterDate;
/// Filter option - today only
///
/// In en, this message translates to:
/// **'Today'**
String get libraryFilterDateToday;
/// Filter option - this week
///
/// In en, this message translates to:
/// **'This Week'**
String get libraryFilterDateWeek;
/// Filter option - this month
///
/// In en, this message translates to:
/// **'This Month'**
String get libraryFilterDateMonth;
/// Filter option - this year
///
/// In en, this message translates to:
/// **'This Year'**
String get libraryFilterDateYear;
/// Badge showing number of active filters
///
/// In en, this message translates to:
/// **'{count} filter(s) active'**
String libraryFilterActive(int count);
/// Relative time - less than a minute ago
///
/// In en, this message translates to:
/// **'Just now'**
String get timeJustNow;
/// Relative time - minutes ago
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 minute ago} other{{count} minutes ago}}'**
String timeMinutesAgo(int count);
/// Relative time - hours ago
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 hour ago} other{{count} hours ago}}'**
String timeHoursAgo(int count);
}
class _AppLocalizationsDelegate
+248
View File
@@ -18,6 +18,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get navHome => 'Startseite';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'Verlauf';
@@ -698,6 +701,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'iOS-Einschränkung: Leere Ordner können nicht ausgewählt werden. Wählen Sie einen Ordner mit mindestens einer Datei.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Spotify Titel in FLAC herunterladen';
@@ -954,6 +961,11 @@ class AppLocalizationsDe extends AppLocalizations {
return '\"$trackName\" already downloaded';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => 'History cleared';
@@ -2001,6 +2013,39 @@ class AppLocalizationsDe extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2223,4 +2268,207 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
+248
View File
@@ -18,6 +18,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -684,6 +687,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -939,6 +946,11 @@ class AppLocalizationsEn extends AppLocalizations {
return '\"$trackName\" already downloaded';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => 'History cleared';
@@ -1986,6 +1998,39 @@ class AppLocalizationsEn extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2208,4 +2253,207 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
+248
View File
@@ -18,6 +18,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -684,6 +687,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -939,6 +946,11 @@ class AppLocalizationsEs extends AppLocalizations {
return '\"$trackName\" already downloaded';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => 'History cleared';
@@ -1986,6 +1998,39 @@ class AppLocalizationsEs extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2208,6 +2253,209 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+248
View File
@@ -18,6 +18,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -684,6 +687,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -939,6 +946,11 @@ class AppLocalizationsFr extends AppLocalizations {
return '\"$trackName\" already downloaded';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => 'History cleared';
@@ -1986,6 +1998,39 @@ class AppLocalizationsFr extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2208,4 +2253,207 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
+248
View File
@@ -18,6 +18,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get navHome => 'होम';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'इतिहास';
@@ -684,6 +687,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -939,6 +946,11 @@ class AppLocalizationsHi extends AppLocalizations {
return '\"$trackName\" already downloaded';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => 'History cleared';
@@ -1986,6 +1998,39 @@ class AppLocalizationsHi extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2208,4 +2253,207 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
+264 -16
View File
@@ -18,6 +18,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get navHome => 'Beranda';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'Riwayat';
@@ -689,6 +692,10 @@ class AppLocalizationsId extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC';
@@ -945,6 +952,11 @@ class AppLocalizationsId extends AppLocalizations {
return '\"$trackName\" sudah diunduh';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => 'Riwayat dihapus';
@@ -1999,6 +2011,39 @@ class AppLocalizationsId extends AppLocalizations {
String get queueClearAllMessage =>
'Apakah Anda yakin ingin menghapus semua unduhan?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'Tidak ada unduhan dalam antrian';
@@ -2133,70 +2178,70 @@ class AppLocalizationsId extends AppLocalizations {
}
@override
String get discographyDownload => 'Unduh Diskografi';
String get discographyDownload => 'Download Discography';
@override
String get discographyDownloadAll => 'Unduh Semua';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount rilis';
return '$count tracks from $albumCount releases';
}
@override
String get discographyAlbumsOnly => 'Album Saja';
String get discographyAlbumsOnly => 'Albums Only';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount album';
return '$count tracks from $albumCount albums';
}
@override
String get discographySinglesOnly => 'Single & EP Saja';
String get discographySinglesOnly => 'Singles & EPs Only';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
return '$count lagu dari $albumCount single';
return '$count tracks from $albumCount singles';
}
@override
String get discographySelectAlbums => 'Pilih Album...';
String get discographySelectAlbums => 'Select Albums...';
@override
String get discographySelectAlbumsSubtitle =>
'Pilih album atau single tertentu';
'Choose specific albums or singles';
@override
String get discographyFetchingTracks => 'Mengambil lagu...';
String get discographyFetchingTracks => 'Fetching tracks...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Mengambil $current dari $total...';
return 'Fetching $current of $total...';
}
@override
String discographySelectedCount(int count) {
return '$count dipilih';
return '$count selected';
}
@override
String get discographyDownloadSelected => 'Unduh yang Dipilih';
String get discographyDownloadSelected => 'Download Selected';
@override
String discographyAddedToQueue(int count) {
return 'Menambahkan $count lagu ke antrian';
return 'Added $count tracks to queue';
}
@override
String discographySkippedDownloaded(int added, int skipped) {
return '$added ditambahkan, $skipped sudah diunduh';
return '$added added, $skipped already downloaded';
}
@override
String get discographyNoAlbums => 'Tidak ada album tersedia';
String get discographyNoAlbums => 'No albums available';
@override
String get discographyFailedToFetch => 'Gagal mengambil beberapa album';
String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@@ -2221,4 +2266,207 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
+248
View File
@@ -18,6 +18,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get navHome => 'ホーム';
@override
String get navLibrary => 'Library';
@override
String get navHistory => '履歴';
@@ -679,6 +682,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Spotify のトラックを FLAC でダウンロード';
@@ -934,6 +941,11 @@ class AppLocalizationsJa extends AppLocalizations {
return '$trackName」は既にダウンロードされています';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => '履歴を消去しました';
@@ -1973,6 +1985,39 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'キューにダウンロードがありません';
@@ -2194,4 +2239,207 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
+248
View File
@@ -18,6 +18,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -684,6 +687,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -939,6 +946,11 @@ class AppLocalizationsKo extends AppLocalizations {
return '\"$trackName\" already downloaded';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => 'History cleared';
@@ -1986,6 +1998,39 @@ class AppLocalizationsKo extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2208,4 +2253,207 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
+248
View File
@@ -18,6 +18,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -684,6 +687,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -939,6 +946,11 @@ class AppLocalizationsNl extends AppLocalizations {
return '\"$trackName\" already downloaded';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => 'History cleared';
@@ -1986,6 +1998,39 @@ class AppLocalizationsNl extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2208,4 +2253,207 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
+248
View File
@@ -18,6 +18,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -684,6 +687,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -939,6 +946,11 @@ class AppLocalizationsPt extends AppLocalizations {
return '\"$trackName\" already downloaded';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => 'History cleared';
@@ -1986,6 +1998,39 @@ class AppLocalizationsPt extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2208,6 +2253,209 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+258 -10
View File
@@ -18,6 +18,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get navHome => 'Главная';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'История';
@@ -74,9 +77,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count треков',
one: '1 трек',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0';
}
@@ -87,9 +90,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count альбомов',
one: '1 альбом',
many: '$count альбомов',
few: '$count альбома',
one: '$count альбом',
);
return '$_temp0';
}
@@ -514,9 +517,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count треков',
one: '1 трек',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0';
}
@@ -548,9 +551,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count релизов',
one: '1 релиз',
many: '$count релизов',
few: '$count релиза',
one: '$count релиз',
);
return '$_temp0';
}
@@ -702,6 +705,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'Ограничение iOS: пустые папки не могут быть выбраны. Выберите папку, содержащую хотя бы один файл.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC';
@@ -926,9 +933,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.';
}
@@ -961,6 +968,11 @@ class AppLocalizationsRu extends AppLocalizations {
return '\"$trackName\" уже скачан';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => 'История очищена';
@@ -976,9 +988,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалено $count $_temp0';
}
@@ -1125,9 +1137,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0';
}
@@ -1569,9 +1581,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count треков',
one: '1 трек',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0';
}
@@ -2025,6 +2037,39 @@ class AppLocalizationsRu extends AppLocalizations {
String get queueClearAllMessage =>
'Вы уверены, что хотите очистить все загрузки?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'Нет загрузок в очереди';
@@ -2092,9 +2137,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.';
}
@@ -2124,9 +2169,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0';
}
@@ -2254,4 +2299,207 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
+248
View File
@@ -18,6 +18,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get navHome => 'Ara';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'Geçmiş';
@@ -691,6 +694,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'iOS\'un sınırlaması: Boş klasörler seçilemiyor. İçinde en az bir dosya bulunan bir klasör seçin.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Spotify şarkılarını FLAC olarak indirin';
@@ -946,6 +953,11 @@ class AppLocalizationsTr extends AppLocalizations {
return '\"$trackName\" zaten indirilmiş';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => 'Geçmiş temizlendi';
@@ -2001,6 +2013,39 @@ class AppLocalizationsTr extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2223,4 +2268,207 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
+248
View File
@@ -18,6 +18,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -684,6 +687,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get setupIosEmptyFolderWarning =>
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
@override
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
@@ -939,6 +946,11 @@ class AppLocalizationsZh extends AppLocalizations {
return '\"$trackName\" already downloaded';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
}
@override
String get snackbarHistoryCleared => 'History cleared';
@@ -1986,6 +1998,39 @@ class AppLocalizationsZh extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2208,6 +2253,209 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get libraryFilterTitle => 'Filters';
@override
String get libraryFilterReset => 'Reset';
@override
String get libraryFilterApply => 'Apply';
@override
String get libraryFilterSource => 'Source';
@override
String get libraryFilterQuality => 'Quality';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@override
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterDate => 'Date Added';
@override
String get libraryFilterDateToday => 'Today';
@override
String get libraryFilterDateWeek => 'This Week';
@override
String get libraryFilterDateMonth => 'This Month';
@override
String get libraryFilterDateYear => 'This Year';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
}
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
}
/// The translations for Chinese, as used in China (`zh_CN`).
+190 -4
View File
@@ -9,8 +9,10 @@
"navHome": "Home",
"@navHome": {"description": "Bottom navigation - Home tab"},
"navLibrary": "Library",
"@navLibrary": {"description": "Bottom navigation - Library tab"},
"navHistory": "History",
"@navHistory": {"description": "Bottom navigation - History tab"},
"@navHistory": {"description": "Bottom navigation - History tab (legacy)"},
"navSettings": "Settings",
"@navSettings": {"description": "Bottom navigation - Settings tab"},
"navStore": "Store",
@@ -481,8 +483,10 @@
"@setupChooseFromFiles": {"description": "iOS file picker option"},
"setupChooseFromFilesSubtitle": "Select iCloud or other location",
"@setupChooseFromFilesSubtitle": {"description": "Subtitle for file picker"},
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
"@setupIosEmptyFolderWarning": {"description": "iOS folder selection warning"},
"setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.",
"@setupIcloudNotSupported": {"description": "Error when user selects iCloud Drive on iOS"},
"setupDownloadInFlac": "Download Spotify tracks in FLAC",
"@setupDownloadInFlac": {"description": "App tagline in setup"},
"setupStepStorage": "Storage",
@@ -668,6 +672,13 @@
"trackName": {"type": "String"}
}
},
"snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library",
"@snackbarAlreadyInLibrary": {
"description": "Snackbar - track already exists in local library",
"placeholders": {
"trackName": {"type": "String"}
}
},
"snackbarHistoryCleared": "History cleared",
"@snackbarHistoryCleared": {"description": "Snackbar - history deleted"},
"snackbarCredentialsSaved": "Credentials saved",
@@ -1458,10 +1469,32 @@
"queueTitle": "Download Queue",
"@queueTitle": {"description": "Queue screen title"},
"queueClearAll": "Clear All",
"queueClearAll": "Clear All",
"@queueClearAll": {"description": "Button - clear all queue items"},
"queueClearAllMessage": "Are you sure you want to clear all downloads?",
"@queueClearAllMessage": {"description": "Clear queue confirmation"},
"queueExportFailed": "Export",
"@queueExportFailed": {"description": "Button - export failed downloads to TXT"},
"queueExportFailedSuccess": "Failed downloads exported to TXT file",
"@queueExportFailedSuccess": {"description": "Success message after exporting failed downloads"},
"queueExportFailedClear": "Clear Failed",
"@queueExportFailedClear": {"description": "Action to clear failed downloads after export"},
"queueExportFailedError": "Failed to export downloads",
"@queueExportFailedError": {"description": "Error message when export fails"},
"settingsAutoExportFailed": "Auto-export failed downloads",
"@settingsAutoExportFailed": {"description": "Setting toggle for auto-export"},
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
"@settingsAutoExportFailedSubtitle": {"description": "Subtitle for auto-export setting"},
"settingsDownloadNetwork": "Download Network",
"@settingsDownloadNetwork": {"description": "Setting for network type preference"},
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
"@settingsDownloadNetworkAny": {"description": "Network option - use any connection"},
"settingsDownloadNetworkWifiOnly": "WiFi Only",
"@settingsDownloadNetworkWifiOnly": {"description": "Network option - only use WiFi"},
"settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.",
"@settingsDownloadNetworkSubtitle": {"description": "Subtitle explaining network preference"},
"queueEmpty": "No downloads in queue",
"@queueEmpty": {"description": "Empty queue state title"},
"queueEmptySubtitle": "Add tracks from the home screen",
@@ -1661,5 +1694,158 @@
"allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.",
"@allFilesAccessDeniedMessage": {"description": "Message when permission is permanently denied"},
"allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.",
"@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"}
"@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"},
"settingsLocalLibrary": "Local Library",
"@settingsLocalLibrary": {"description": "Settings menu item - local library"},
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"},
"libraryTitle": "Local Library",
"@libraryTitle": {"description": "Library settings page title"},
"libraryStatus": "Library Status",
"@libraryStatus": {"description": "Section header for library status"},
"libraryScanSettings": "Scan Settings",
"@libraryScanSettings": {"description": "Section header for scan settings"},
"libraryEnableLocalLibrary": "Enable Local Library",
"@libraryEnableLocalLibrary": {"description": "Toggle to enable library scanning"},
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
"@libraryEnableLocalLibrarySubtitle": {"description": "Subtitle for enable toggle"},
"libraryFolder": "Library Folder",
"@libraryFolder": {"description": "Folder selection setting"},
"libraryFolderHint": "Tap to select folder",
"@libraryFolderHint": {"description": "Placeholder when no folder selected"},
"libraryShowDuplicateIndicator": "Show Duplicate Indicator",
"@libraryShowDuplicateIndicator": {"description": "Toggle for duplicate indicator in search"},
"libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks",
"@libraryShowDuplicateIndicatorSubtitle": {"description": "Subtitle for duplicate indicator toggle"},
"libraryActions": "Actions",
"@libraryActions": {"description": "Section header for library actions"},
"libraryScan": "Scan Library",
"@libraryScan": {"description": "Button to start library scan"},
"libraryScanSubtitle": "Scan for audio files",
"@libraryScanSubtitle": {"description": "Subtitle for scan button"},
"libraryScanSelectFolderFirst": "Select a folder first",
"@libraryScanSelectFolderFirst": {"description": "Message when trying to scan without folder"},
"libraryCleanupMissingFiles": "Cleanup Missing Files",
"@libraryCleanupMissingFiles": {"description": "Button to remove entries for missing files"},
"libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist",
"@libraryCleanupMissingFilesSubtitle": {"description": "Subtitle for cleanup button"},
"libraryClear": "Clear Library",
"@libraryClear": {"description": "Button to clear all library entries"},
"libraryClearSubtitle": "Remove all scanned tracks",
"@libraryClearSubtitle": {"description": "Subtitle for clear button"},
"libraryClearConfirmTitle": "Clear Library",
"@libraryClearConfirmTitle": {"description": "Dialog title for clear confirmation"},
"libraryClearConfirmMessage": "This will remove all scanned tracks from your library. Your actual music files will not be deleted.",
"@libraryClearConfirmMessage": {"description": "Dialog message for clear confirmation"},
"libraryAbout": "About Local Library",
"@libraryAbout": {"description": "Section header for about info"},
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.",
"@libraryAboutDescription": {"description": "Description of local library feature"},
"libraryTracksCount": "{count} tracks",
"@libraryTracksCount": {
"description": "Track count in library",
"placeholders": {
"count": {"type": "int"}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
"placeholders": {
"time": {"type": "String"}
}
},
"libraryLastScannedNever": "Never",
"@libraryLastScannedNever": {"description": "Shown when library has never been scanned"},
"libraryScanning": "Scanning...",
"@libraryScanning": {"description": "Status during scan"},
"libraryScanProgress": "{progress}% of {total} files",
"@libraryScanProgress": {
"description": "Scan progress display",
"placeholders": {
"progress": {"type": "String"},
"total": {"type": "int"}
}
},
"libraryInLibrary": "In Library",
"@libraryInLibrary": {"description": "Badge shown on tracks that exist in local library"},
"libraryRemovedMissingFiles": "Removed {count} missing files from library",
"@libraryRemovedMissingFiles": {
"description": "Snackbar after cleanup",
"placeholders": {
"count": {"type": "int"}
}
},
"libraryCleared": "Library cleared",
"@libraryCleared": {"description": "Snackbar after clearing library"},
"libraryStorageAccessRequired": "Storage Access Required",
"@libraryStorageAccessRequired": {"description": "Dialog title for storage permission"},
"libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.",
"@libraryStorageAccessMessage": {"description": "Dialog message for storage permission"},
"libraryFolderNotExist": "Selected folder does not exist",
"@libraryFolderNotExist": {"description": "Error when folder doesn't exist"},
"librarySourceDownloaded": "Downloaded",
"@librarySourceDownloaded": {"description": "Badge for tracks downloaded via SpotiFLAC"},
"librarySourceLocal": "Local",
"@librarySourceLocal": {"description": "Badge for tracks from local library scan"},
"libraryFilterAll": "All",
"@libraryFilterAll": {"description": "Filter chip - show all library items"},
"libraryFilterDownloaded": "Downloaded",
"@libraryFilterDownloaded": {"description": "Filter chip - show only downloaded items"},
"libraryFilterLocal": "Local",
"@libraryFilterLocal": {"description": "Filter chip - show only local library items"},
"libraryFilterTitle": "Filters",
"@libraryFilterTitle": {"description": "Filter bottom sheet title"},
"libraryFilterReset": "Reset",
"@libraryFilterReset": {"description": "Reset all filters button"},
"libraryFilterApply": "Apply",
"@libraryFilterApply": {"description": "Apply filters button"},
"libraryFilterSource": "Source",
"@libraryFilterSource": {"description": "Filter section - source type"},
"libraryFilterQuality": "Quality",
"@libraryFilterQuality": {"description": "Filter section - audio quality"},
"libraryFilterQualityHiRes": "Hi-Res (24bit)",
"@libraryFilterQualityHiRes": {"description": "Filter option - high resolution audio"},
"libraryFilterQualityCD": "CD (16bit)",
"@libraryFilterQualityCD": {"description": "Filter option - CD quality audio"},
"libraryFilterQualityLossy": "Lossy",
"@libraryFilterQualityLossy": {"description": "Filter option - lossy compressed audio"},
"libraryFilterFormat": "Format",
"@libraryFilterFormat": {"description": "Filter section - file format"},
"libraryFilterDate": "Date Added",
"@libraryFilterDate": {"description": "Filter section - date range"},
"libraryFilterDateToday": "Today",
"@libraryFilterDateToday": {"description": "Filter option - today only"},
"libraryFilterDateWeek": "This Week",
"@libraryFilterDateWeek": {"description": "Filter option - this week"},
"libraryFilterDateMonth": "This Month",
"@libraryFilterDateMonth": {"description": "Filter option - this month"},
"libraryFilterDateYear": "This Year",
"@libraryFilterDateYear": {"description": "Filter option - this year"},
"libraryFilterActive": "{count} filter(s) active",
"@libraryFilterActive": {
"description": "Badge showing number of active filters",
"placeholders": {
"count": {"type": "int"}
}
},
"timeJustNow": "Just now",
"@timeJustNow": {"description": "Relative time - less than a minute ago"},
"timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
"@timeMinutesAgo": {
"description": "Relative time - minutes ago",
"placeholders": {
"count": {"type": "int"}
}
},
"timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
"@timeHoursAgo": {
"description": "Relative time - hours ago",
"placeholders": {
"count": {"type": "int"}
}
}
}
+10 -10
View File
@@ -85,7 +85,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@historyTracksCount": {
"description": "Track count with plural form",
"placeholders": {
@@ -94,7 +94,7 @@
}
}
},
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} =1 {1 альбом} other {{count} альбомов}}",
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} other {{count} альбомов}}",
"@historyAlbumsCount": {
"description": "Album count with plural form",
"placeholders": {
@@ -624,7 +624,7 @@
"@albumTitle": {
"description": "Album screen title"
},
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@albumTracks": {
"description": "Album track count",
"placeholders": {
@@ -661,7 +661,7 @@
"@artistCompilations": {
"description": "Section header for compilations"
},
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} =1 {1 релиз} other {{count} релизов}}",
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} other {{count} релизов}}",
"@artistReleases": {
"description": "Artist release count",
"placeholders": {
@@ -1136,7 +1136,7 @@
"@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items"
},
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks",
"placeholders": {
@@ -1206,7 +1206,7 @@
"@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed"
},
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
"@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted",
"placeholders": {
@@ -1413,7 +1413,7 @@
"@selectionTapToSelect": {
"description": "Hint - how to select items"
},
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
"@selectionDeleteTracks": {
"description": "Delete button with count",
"placeholders": {
@@ -1989,7 +1989,7 @@
}
}
},
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@tracksCount": {
"description": "Track count display",
"placeholders": {
@@ -2645,7 +2645,7 @@
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
},
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count",
"placeholders": {
@@ -2684,7 +2684,7 @@
"@downloadedAlbumTapToSelect": {
"description": "Selection hint"
},
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
"@downloadedAlbumDeleteCount": {
"description": "Delete button text with count",
"placeholders": {
+5 -5
View File
@@ -85,7 +85,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historyTracksCount": "{count, plural, one {}=1{1 şarkı} other{{count} şarkı}}",
"historyTracksCount": "{count, plural, one {1 şarkı} other {{count} şarkı}}",
"@historyTracksCount": {
"description": "Track count with plural form",
"placeholders": {
@@ -94,7 +94,7 @@
}
}
},
"historyAlbumsCount": "{count, plural, one {}=1{1 albüm} other{{count} albüm}}",
"historyAlbumsCount": "{count, plural, one {1 albüm} other {{count} albüm}}",
"@historyAlbumsCount": {
"description": "Album count with plural form",
"placeholders": {
@@ -624,7 +624,7 @@
"@albumTitle": {
"description": "Album screen title"
},
"albumTracks": "{count, plural, one {}=1{1 şarkı} other{{count} şarkı}}",
"albumTracks": "{count, plural, one {1 şarkı} other {{count} şarkı}}",
"@albumTracks": {
"description": "Album track count",
"placeholders": {
@@ -1136,7 +1136,7 @@
"@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items"
},
"dialogDeleteSelectedMessage": "{count} {count, plural, one {}=1{şarkıyı} other{şarkıyı}} geçmişten silmeye emin misiniz?\n\nBu işlem seçilenleri cihazınızdan da silecektir.",
"dialogDeleteSelectedMessage": "{count} {count, plural, one {şarkıyı} other {şarkıyı}} geçmişten silmeye emin misiniz?\n\nBu işlem seçilenleri cihazınızdan da silecektir.",
"@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks",
"placeholders": {
@@ -1206,7 +1206,7 @@
"@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed"
},
"snackbarDeletedTracks": "{count} {count, plural, one {}=1{şarkı} other{şarkı}} silindi",
"snackbarDeletedTracks": "{count} {count, plural, one {şarkı} other {şarkı}} silindi",
"@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted",
"placeholders": {
-1
View File
@@ -43,7 +43,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
void initState() {
super.initState();
_initializeExtensions();
// Trigger history provider initialization without subscribing to updates.
ref.read(downloadHistoryProvider);
}
+29 -12
View File
@@ -31,11 +31,16 @@ class AppSettings {
final String albumFolderStructure;
final bool showExtensionStore;
final String locale;
final bool enableLossyOption;
final String lossyFormat;
final String lossyBitrate; // e.g., 'mp3_320', 'mp3_256', 'mp3_192', 'mp3_128', 'opus_128', 'opus_96', 'opus_64'
final String lyricsMode;
final 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
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
// Local Library Settings
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks
const AppSettings({
this.defaultService = 'tidal',
@@ -65,11 +70,15 @@ class AppSettings {
this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true,
this.locale = 'system',
this.enableLossyOption = false,
this.lossyFormat = 'mp3',
this.lossyBitrate = 'mp3_320',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.useAllFilesAccess = false,
this.autoExportFailedDownloads = false,
this.downloadNetworkMode = 'any',
// Local Library defaults
this.localLibraryEnabled = false,
this.localLibraryPath = '',
this.localLibraryShowDuplicates = true,
});
AppSettings copyWith({
@@ -101,11 +110,15 @@ class AppSettings {
String? albumFolderStructure,
bool? showExtensionStore,
String? locale,
bool? enableLossyOption,
String? lossyFormat,
String? lossyBitrate,
String? lyricsMode,
String? tidalHighFormat,
bool? useAllFilesAccess,
bool? autoExportFailedDownloads,
String? downloadNetworkMode,
// Local Library
bool? localLibraryEnabled,
String? localLibraryPath,
bool? localLibraryShowDuplicates,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -135,11 +148,15 @@ class AppSettings {
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
enableLossyOption: enableLossyOption ?? this.enableLossyOption,
lossyFormat: lossyFormat ?? this.lossyFormat,
lossyBitrate: lossyBitrate ?? this.lossyBitrate,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
// Local Library
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
);
}
+14 -6
View File
@@ -36,11 +36,16 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
enableLossyOption: json['enableLossyOption'] as bool? ?? false,
lossyFormat: json['lossyFormat'] as String? ?? 'mp3',
lossyBitrate: json['lossyBitrate'] as String? ?? 'mp3_320',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
autoExportFailedDownloads:
json['autoExportFailedDownloads'] as bool? ?? false,
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
localLibraryPath: json['localLibraryPath'] as String? ?? '',
localLibraryShowDuplicates:
json['localLibraryShowDuplicates'] as bool? ?? true,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -72,9 +77,12 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
'enableLossyOption': instance.enableLossyOption,
'lossyFormat': instance.lossyFormat,
'lossyBitrate': instance.lossyBitrate,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'useAllFilesAccess': instance.useAllFilesAccess,
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
'downloadNetworkMode': instance.downloadNetworkMode,
'localLibraryEnabled': instance.localLibraryEnabled,
'localLibraryPath': instance.localLibraryPath,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
};
+261 -113
View File
@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -150,15 +151,12 @@ class DownloadHistoryState {
.map((item) => MapEntry(item.isrc!, item)),
);
/// O(1) check if spotify_id exists
bool isDownloaded(String spotifyId) =>
_downloadedSpotifyIds.contains(spotifyId);
/// O(1) lookup by spotify_id
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
_bySpotifyId[spotifyId];
/// O(1) lookup by ISRC
DownloadHistoryItem? getByIsrc(String isrc) =>
_byIsrc[isrc];
@@ -177,7 +175,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return DownloadHistoryState();
}
/// Synchronously schedule load - ensures it runs before any UI renders
void _loadFromDatabaseSync() {
if (_isLoaded) return;
_isLoaded = true;
@@ -193,7 +190,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_historyLog.i('Migrated history from SharedPreferences to SQLite');
}
// Migrate iOS paths if container UUID changed after app update
if (Platform.isIOS) {
final pathsMigrated = await _db.migrateIosContainerPaths();
if (pathsMigrated) {
@@ -264,12 +260,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return state.getBySpotifyId(spotifyId);
}
/// O(1) lookup by ISRC
DownloadHistoryItem? getByIsrc(String isrc) {
return state.getByIsrc(isrc);
}
/// Async version with database lookup (for cases where in-memory might be stale)
Future<DownloadHistoryItem?> getBySpotifyIdAsync(String spotifyId) async {
final inMemory = state.getBySpotifyId(spotifyId);
if (inMemory != null) return inMemory;
@@ -286,7 +280,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
});
}
/// Get database stats for debugging
Future<int> getDatabaseCount() async {
return await _db.getCount();
}
@@ -722,7 +715,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist);
// New option: Singles folder inside Artist folder
if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles';
@@ -736,7 +728,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
// Existing behavior: Separate Albums/ and Singles/ at root
if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
await _ensureDirExists(singlesPath, label: 'Singles folder');
@@ -804,7 +795,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.trim();
}
/// Extract year from release date (format: "2005-06-13" or "2005")
String? _extractYear(String? releaseDate) {
if (releaseDate == null || releaseDate.isEmpty) return null;
final match = _yearRegex.firstMatch(releaseDate);
@@ -1023,12 +1013,89 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
void removeItem(String id) {
void removeItem(String id) {
final items = state.items.where((item) => item.id != id).toList();
state = state.copyWith(items: items);
_saveQueueToStorage();
}
Future<String?> exportFailedDownloads() async {
final failedItems = state.items
.where((item) => item.status == DownloadStatus.failed)
.toList();
if (failedItems.isEmpty) {
_log.d('No failed downloads to export');
return null;
}
try {
String baseDir = state.outputDir;
if (baseDir.isEmpty) {
final dir = await getApplicationDocumentsDirectory();
baseDir = dir.path;
}
final failedDownloadsDir = '$baseDir/failed_downloads';
final failedDir = Directory(failedDownloadsDir);
if (!await failedDir.exists()) {
await failedDir.create(recursive: true);
}
// Use date-only format for daily grouping (YYYY-MM-DD)
final now = DateTime.now();
final dateStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final fileName = 'failed_downloads_$dateStr.txt';
final filePath = '$failedDownloadsDir/$fileName';
final file = File(filePath);
final bool fileExists = await file.exists();
final buffer = StringBuffer();
if (!fileExists) {
buffer.writeln('# SpotiFLAC Failed Downloads');
buffer.writeln('# Date: $dateStr');
buffer.writeln('#');
buffer.writeln('# Format: [Time] Track - Artist | URL | Error');
buffer.writeln('');
}
final timeStr = '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
for (final item in failedItems) {
final track = item.track;
final spotifyUrl = track.id.startsWith('deezer:')
? 'https://www.deezer.com/track/${track.id.substring(7)}'
: 'https://open.spotify.com/track/${track.id}';
final error = item.error ?? 'Unknown error';
buffer.writeln('[$timeStr] ${track.name} - ${track.artistName} | $spotifyUrl | $error');
}
if (fileExists) {
await file.writeAsString(buffer.toString(), mode: FileMode.append);
_log.i('Appended ${failedItems.length} failed downloads to: $filePath');
} else {
await file.writeAsString(buffer.toString());
_log.i('Created new failed downloads file: $filePath');
}
return filePath;
} catch (e) {
_log.e('Failed to export failed downloads: $e');
return null;
}
}
void clearFailedDownloads() {
final items = state.items
.where((item) => item.status != DownloadStatus.failed)
.toList();
state = state.copyWith(items: items);
_saveQueueToStorage();
_log.d('Cleared failed downloads from queue');
}
Future<void> _runPostProcessingHooks(String filePath, Track track) async {
try {
final settings = ref.read(settingsProvider);
@@ -1075,7 +1142,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
/// Same logic as Go backend cover.go
String _upgradeToMaxQualityCover(String coverUrl) {
const spotifySize300 = 'ab67616d00001e02';
const spotifySize640 = 'ab67616d0000b273';
@@ -1192,7 +1258,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
durationMs: durationMs,
);
// Skip instrumental tracks (no lyrics to embed)
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
@@ -1323,7 +1388,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('MP3 Metadata map content: $metadata');
if (settings.embedLyrics) {
final lyricsMode = settings.lyricsMode;
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) {
try {
final durationMs = track.duration * 1000;
@@ -1336,12 +1405,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
if (lrcContent.isNotEmpty) {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
if (shouldEmbed) {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
}
if (shouldSaveExternal) {
try {
final lrcPath = mp3Path.replaceAll(RegExp(r'\.mp3$', caseSensitive: false), '.lrc');
await File(lrcPath).writeAsString(lrcContent);
_log.d('External LRC file saved: $lrcPath');
} catch (e) {
_log.w('Failed to save external LRC file: $e');
}
}
}
} catch (e) {
_log.w('Failed to fetch lyrics for MP3 embedding: $e');
_log.w('Failed to fetch lyrics for MP3: $e');
}
}
@@ -1461,7 +1542,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Opus Metadata map content: $metadata');
if (settings.embedLyrics) {
final lyricsMode = settings.lyricsMode;
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) {
try {
final durationMs = track.duration * 1000;
@@ -1474,11 +1559,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
if (lrcContent.isNotEmpty) {
metadata['LYRICS'] = lrcContent;
_log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)');
if (shouldEmbed) {
metadata['LYRICS'] = lrcContent;
_log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)');
}
if (shouldSaveExternal) {
try {
final lrcPath = opusPath.replaceAll(RegExp(r'\.opus$', caseSensitive: false), '.lrc');
await File(lrcPath).writeAsString(lrcContent);
_log.d('External LRC file saved: $lrcPath');
} catch (e) {
_log.w('Failed to save external LRC file: $e');
}
}
}
} catch (e) {
_log.w('Failed to fetch lyrics for Opus embedding: $e');
_log.w('Failed to fetch lyrics for Opus: $e');
}
}
@@ -1513,9 +1610,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
Future<void> _processQueue() async {
Future<void> _processQueue() async {
if (state.isProcessing) return;
// Check network connectivity before starting
final settings = ref.read(settingsProvider);
if (settings.downloadNetworkMode == 'wifi_only') {
final connectivityResult = await Connectivity().checkConnectivity();
final hasWifi = connectivityResult.contains(ConnectivityResult.wifi);
if (!hasWifi) {
_log.w('WiFi-only mode enabled but no WiFi connection. Queue paused.');
state = state.copyWith(isProcessing: false, isPaused: true);
return;
}
}
state = state.copyWith(isProcessing: true);
_log.i('Starting queue processing...');
@@ -1542,11 +1651,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
if (state.outputDir.isEmpty) {
if (state.outputDir.isEmpty) {
_log.d('Output dir empty, initializing...');
await _initOutputDir();
}
// iOS: Validate that outputDir is writable (not iCloud Drive which Go can't access)
if (Platform.isIOS && state.outputDir.isNotEmpty) {
final isICloudPath = state.outputDir.contains('Mobile Documents') ||
state.outputDir.contains('CloudDocs') ||
state.outputDir.contains('com~apple~CloudDocs');
if (isICloudPath) {
_log.w('iOS: iCloud Drive path detected, falling back to app Documents folder');
_log.w('Go backend cannot write to iCloud Drive due to iOS sandboxing');
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/SpotiFLAC');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
state = state.copyWith(outputDir: musicDir.path);
}
}
if (state.outputDir.isEmpty) {
_log.d('Using fallback directory...');
final dir = await getApplicationDocumentsDirectory();
@@ -1587,7 +1713,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_downloadCount = 0;
}
_log.i(
_log.i(
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
);
if (_totalQueuedAtStart > 0) {
@@ -1595,6 +1721,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
completedCount: _completedInSession,
failedCount: _failedInSession,
);
// Auto-export failed downloads if enabled
final settings = ref.read(settingsProvider);
if (settings.autoExportFailedDownloads && _failedInSession > 0) {
final exportPath = await exportFailedDownloads();
if (exportPath != null) {
_log.i('Auto-exported failed downloads to: $exportPath');
}
}
}
_log.i('Queue processing finished');
@@ -1804,12 +1939,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
final quality = item.qualityOverride ?? state.audioQuality;
// For LOSSY, we need to download FLAC first then convert
// Servers don't support lossy quality directly
final downloadQuality = quality == 'LOSSY' ? 'LOSSLESS' : quality;
// Fetch extended metadata (genre, label) from Deezer if available
String? genre;
String? label;
@@ -1858,10 +1988,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (useExtensions) {
_log.d('Using extension providers for download');
_log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}${quality == 'LOSSY' ? ' (downloading as LOSSLESS for conversion)' : ''}',
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
);
_log.d('Output dir: $outputDir');
result = await PlatformBridge.downloadWithExtensions(
result = await PlatformBridge.downloadWithExtensions(
isrc: trackToDownload.isrc ?? '',
spotifyId: trackToDownload.id,
trackName: trackToDownload.name,
@@ -1871,7 +2001,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: downloadQuality,
quality: quality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
@@ -1881,11 +2011,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
genre: genre,
label: label,
lyricsMode: settings.lyricsMode,
preferredService: item.service,
);
} else if (state.autoFallback) {
_log.d('Using auto-fallback mode');
_log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}${quality == 'LOSSY' ? ' (downloading as LOSSLESS for conversion)' : ''}',
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
);
_log.d('Output dir: $outputDir');
result = await PlatformBridge.downloadWithFallback(
@@ -1898,7 +2029,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: downloadQuality,
quality: quality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
@@ -1921,7 +2052,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: downloadQuality,
quality: quality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
@@ -1956,10 +2087,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
final wasExisting = filePath != null && filePath.startsWith('EXISTS:');
// Check if file already existed (detected via ISRC match in Go backend)
final wasExisting = result['already_exists'] == true;
if (wasExisting) {
filePath = filePath.substring(7); // Remove "EXISTS:" prefix
_log.i('Using existing file: $filePath');
_log.i('File already exists in library: $filePath');
}
_log.i('Download success, file: $filePath');
@@ -1980,9 +2111,75 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
if (filePath != null && filePath.endsWith('.m4a')) {
_log.d(
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
);
// For HIGH quality (Tidal AAC 320kbps), convert to MP3 or Opus
if (quality == 'HIGH') {
final tidalHighFormat = settings.tidalHighFormat;
_log.i('Tidal HIGH quality download, converting M4A to $tidalHighFormat...');
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3';
final convertedPath = await FFmpegService.convertM4aToLossy(
filePath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: true,
);
if (convertedPath != null) {
filePath = convertedPath;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
_log.i('Successfully converted M4A to $format: $convertedPath');
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (format == 'mp3') {
await _embedMetadataToMp3(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
_log.d('Metadata embedded successfully');
} else {
_log.w('M4A to $format conversion failed, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('M4A conversion process failed: $e, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} else {
_log.d(
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
);
try {
final file = File(filePath);
@@ -2084,6 +2281,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} catch (e) {
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
}
}
}
final itemAfterDownload = state.items.firstWhere(
@@ -2106,74 +2304,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
if (quality == 'LOSSY' && filePath != null && filePath.endsWith('.flac')) {
if (wasExisting) {
_log.i('Lossy requested but existing FLAC found - skipping conversion to preserve original file');
} else {
final lossyFormat = settings.lossyFormat;
final lossyBitrate = settings.lossyBitrate;
_log.i('Lossy quality selected, converting FLAC to $lossyFormat ($lossyBitrate)...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.97,
);
try {
final convertedPath = await FFmpegService.convertFlacToLossy(
filePath,
format: lossyFormat,
bitrate: lossyBitrate,
deleteOriginal: true,
);
if (convertedPath != null) {
filePath = convertedPath;
// Extract bitrate for display (e.g., 'mp3_320' -> '320kbps')
final bitrateDisplay = lossyBitrate.contains('_')
? '${lossyBitrate.split('_').last}kbps'
: (lossyFormat == 'opus' ? '128kbps' : '320kbps');
actualQuality = '${lossyFormat.toUpperCase()} $bitrateDisplay';
_log.i('Successfully converted to $lossyFormat ($bitrateDisplay): $convertedPath');
// Embed metadata and cover for both MP3 and Opus
_log.i('Embedding metadata to $lossyFormat...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
final lossyBackendGenre = result['genre'] as String?;
final lossyBackendLabel = result['label'] as String?;
final lossyBackendCopyright = result['copyright'] as String?;
if (lossyFormat == 'mp3') {
await _embedMetadataToMp3(
convertedPath,
trackToDownload,
genre: lossyBackendGenre ?? genre,
label: lossyBackendLabel ?? label,
copyright: lossyBackendCopyright,
);
} else if (lossyFormat == 'opus') {
await _embedMetadataToOpus(
convertedPath,
trackToDownload,
genre: lossyBackendGenre ?? genre,
label: lossyBackendLabel ?? label,
copyright: lossyBackendCopyright,
);
}
} else {
_log.w('$lossyFormat conversion failed, keeping FLAC file');
}
} catch (e) {
_log.e('Lossy conversion error: $e, keeping FLAC file');
}
}
}
updateItemStatus(
item.id,
DownloadStatus.completed,
@@ -2187,11 +2317,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_completedInSession++;
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final existingInHistory = historyNotifier.getBySpotifyId(trackToDownload.id) ??
(trackToDownload.isrc != null ? historyNotifier.getByIsrc(trackToDownload.isrc!) : null);
if (wasExisting && existingInHistory != null) {
_log.i('Track already in library, skipping history update');
await _notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: _completedInSession,
totalCount: _totalQueuedAtStart,
alreadyInLibrary: true,
);
removeItem(item.id);
return;
}
await _notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: _completedInSession,
totalCount: _totalQueuedAtStart,
alreadyInLibrary: wasExisting,
);
if (filePath != null) {
+1 -13
View File
@@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('ExploreProvider');
/// Represents an item in a Spotify home section
class ExploreItem {
final String id;
final String uri;
@@ -50,7 +49,6 @@ class ExploreItem {
}
}
/// Represents a section in Spotify home feed
class ExploreSection {
final String uri;
final String title;
@@ -79,7 +77,6 @@ class ExploreSection {
}
}
/// State for explore/home feed
class ExploreState {
final bool isLoading;
final String? error;
@@ -114,7 +111,6 @@ class ExploreState {
}
}
/// Calculate greeting based on local device time
String _getLocalGreeting() {
final hour = DateTime.now().hour;
if (hour >= 5 && hour < 12) {
@@ -139,7 +135,6 @@ bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
return true;
}
/// Provider for explore/home feed state
class ExploreNotifier extends Notifier<ExploreState> {
@override
ExploreState build() {
@@ -150,7 +145,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// Don't refetch if we have data and it's less than 5 minutes old
if (!forceRefresh &&
state.hasContent &&
state.lastFetched != null &&
@@ -167,11 +161,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
state = state.copyWith(isLoading: true, error: null);
try {
// Find any extension with homeFeed capability
final extState = ref.read(extensionProvider);
_log.d('Extensions count: ${extState.extensions.length}');
// Look for extensions with homeFeed capability (prefer spotify-web)
Extension? targetExt;
for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) {
@@ -225,14 +217,11 @@ class ExploreNotifier extends Notifier<ExploreState> {
_log.i('Fetched ${sections.length} sections');
// Debug: log first section items
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
final firstItem = sections.first.items.first;
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
}
// Always use local device time for greeting to avoid timezone issues
// Extension greeting may use wrong timezone (UTC or Spotify account timezone)
final localGreeting = _getLocalGreeting();
_log.d('Greeting from extension: $greeting, using local: $localGreeting');
@@ -251,15 +240,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
}
}
/// Clear cached data
void clear() {
state = const ExploreState();
}
/// Refresh home feed
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
}
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
return ExploreNotifier();
});
+4 -10
View File
@@ -452,7 +452,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return const ExtensionState();
}
/// Initialize the extension system
Future<void> initialize(String extensionsDir, String dataDir) async {
if (state.isInitialized) return;
@@ -485,7 +484,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Refresh the list of installed extensions
Future<void> refreshExtensions() async {
try {
final list = await PlatformBridge.getInstalledExtensions();
@@ -493,7 +491,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
state = state.copyWith(extensions: extensions);
_log.d('Loaded ${extensions.length} extensions');
// Log search behavior for extensions that have it
for (final ext in extensions) {
if (ext.searchBehavior != null) {
_log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}');
@@ -505,6 +502,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
void clearError() {
state = state.copyWith(error: null);
}
@@ -550,7 +548,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Uninstall/remove an extension
Future<bool> removeExtension(String extensionId) async {
state = state.copyWith(isLoading: true, error: null);
@@ -567,6 +564,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
try {
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
@@ -603,7 +601,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Get settings for an extension
Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
try {
return await PlatformBridge.getExtensionSettings(extensionId);
@@ -623,7 +620,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Load provider priority order
Future<void> loadProviderPriority() async {
try {
final priority = await PlatformBridge.getProviderPriority();
@@ -633,6 +629,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
Future<void> setProviderPriority(List<String> priority) async {
try {
await PlatformBridge.setProviderPriority(priority);
@@ -644,7 +641,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Load metadata provider priority order
Future<void> loadMetadataProviderPriority() async {
try {
final priority = await PlatformBridge.getMetadataProviderPriority();
@@ -665,7 +661,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Cleanup all extensions (call on app close)
Future<void> cleanup() async {
try {
await PlatformBridge.cleanupExtensions();
@@ -683,7 +678,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// Get all enabled extensions
List<Extension> get enabledExtensions {
return state.extensions.where((ext) => ext.enabled).toList();
}
@@ -698,7 +692,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return providers;
}
/// Get all metadata providers (built-in + extensions)
List<String> getAllMetadataProviders() {
final providers = ['deezer', 'spotify'];
for (final ext in state.extensions) {
@@ -708,6 +701,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
return providers;
}
List<Extension> get searchProviders {
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
}
+302
View File
@@ -0,0 +1,302 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LocalLibrary');
const _lastScannedAtKey = 'local_library_last_scanned_at';
class LocalLibraryState {
final List<LocalLibraryItem> items;
final bool isScanning;
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
final int scanErrorCount;
final DateTime? lastScannedAt;
final Set<String> _isrcSet;
final Set<String> _trackKeySet;
final Map<String, LocalLibraryItem> _byIsrc;
LocalLibraryState({
this.items = const [],
this.isScanning = false,
this.scanProgress = 0,
this.scanCurrentFile,
this.scanTotalFiles = 0,
this.scanErrorCount = 0,
this.lastScannedAt,
}) : _isrcSet = items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => item.isrc!)
.toSet(),
_trackKeySet = items.map((item) => item.matchKey).toSet(),
_byIsrc = Map.fromEntries(
items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
);
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
bool hasTrack(String trackName, String artistName) {
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
return _trackKeySet.contains(key);
}
LocalLibraryItem? getByIsrc(String isrc) => _byIsrc[isrc];
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
return items.where((item) => item.matchKey == key).firstOrNull;
}
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
if (isrc != null && isrc.isNotEmpty && hasIsrc(isrc)) {
return true;
}
if (trackName != null && artistName != null) {
return hasTrack(trackName, artistName);
}
return false;
}
LocalLibraryState copyWith({
List<LocalLibraryItem>? items,
bool? isScanning,
double? scanProgress,
String? scanCurrentFile,
int? scanTotalFiles,
int? scanErrorCount,
DateTime? lastScannedAt,
}) {
return LocalLibraryState(
items: items ?? this.items,
isScanning: isScanning ?? this.isScanning,
scanProgress: scanProgress ?? this.scanProgress,
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
);
}
}
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
Timer? _progressTimer;
bool _isLoaded = false;
@override
LocalLibraryState build() {
ref.onDispose(() {
_progressTimer?.cancel();
});
Future.microtask(() async {
await _loadFromDatabase();
});
return LocalLibraryState();
}
Future<void> _loadFromDatabase() async {
if (_isLoaded) return;
_isLoaded = true;
try {
final jsonList = await _db.getAll();
final items = jsonList
.map((e) => LocalLibraryItem.fromJson(e))
.toList();
DateTime? lastScannedAt;
try {
final prefs = await SharedPreferences.getInstance();
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
}
} catch (e) {
_log.w('Failed to load lastScannedAt: $e');
}
state = state.copyWith(items: items, lastScannedAt: lastScannedAt);
_log.i('Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt');
} catch (e, stack) {
_log.e('Failed to load library from database: $e', e, stack);
}
}
Future<void> reloadFromStorage() async {
_isLoaded = false;
await _loadFromDatabase();
}
Future<void> startScan(String folderPath) async {
if (state.isScanning) {
_log.w('Scan already in progress');
return;
}
_log.i('Starting library scan: $folderPath');
state = state.copyWith(
isScanning: true,
scanProgress: 0,
scanCurrentFile: null,
scanTotalFiles: 0,
scanErrorCount: 0,
);
try {
final cacheDir = await getApplicationCacheDirectory();
final coverCacheDir = '${cacheDir.path}/library_covers';
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
_log.i('Cover cache directory set to: $coverCacheDir');
} catch (e) {
_log.w('Failed to set cover cache directory: $e');
}
_startProgressPolling();
try {
final results = await PlatformBridge.scanLibraryFolder(folderPath);
final items = <LocalLibraryItem>[];
for (final json in results) {
final item = LocalLibraryItem.fromJson(json);
items.add(item);
}
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
final now = DateTime.now();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
_log.d('Saved lastScannedAt: $now');
} catch (e) {
_log.w('Failed to save lastScannedAt: $e');
}
state = state.copyWith(
items: items,
isScanning: false,
scanProgress: 100,
lastScannedAt: now,
);
_log.i('Scan complete: ${items.length} tracks found');
} catch (e, stack) {
_log.e('Library scan failed: $e', e, stack);
state = state.copyWith(isScanning: false);
} finally {
_stopProgressPolling();
}
}
void _startProgressPolling() {
_progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
try {
final progress = await PlatformBridge.getLibraryScanProgress();
state = state.copyWith(
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
scanCurrentFile: progress['current_file'] as String?,
scanTotalFiles: progress['total_files'] as int? ?? 0,
scanErrorCount: progress['error_count'] as int? ?? 0,
);
if (progress['is_complete'] == true) {
_stopProgressPolling();
}
} catch (_) {}
});
}
void _stopProgressPolling() {
_progressTimer?.cancel();
_progressTimer = null;
}
Future<void> cancelScan() async {
if (!state.isScanning) return;
_log.i('Cancelling library scan');
await PlatformBridge.cancelLibraryScan();
state = state.copyWith(isScanning: false);
_stopProgressPolling();
}
Future<int> cleanupMissingFiles() async {
final removed = await _db.cleanupMissingFiles();
if (removed > 0) {
await reloadFromStorage();
}
return removed;
}
Future<void> clearLibrary() async {
await _db.clearAll();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_lastScannedAtKey);
} catch (e) {
_log.w('Failed to clear lastScannedAt: $e');
}
state = LocalLibraryState();
_log.i('Library cleared');
}
Future<void> removeItem(String id) async {
await _db.delete(id);
state = state.copyWith(
items: state.items.where((item) => item.id != id).toList(),
);
}
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
return state.existsInLibrary(
isrc: isrc,
trackName: trackName,
artistName: artistName,
);
}
LocalLibraryItem? getByIsrc(String isrc) {
return state.getByIsrc(isrc);
}
LocalLibraryItem? findExisting({String? isrc, String? trackName, String? artistName}) {
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
}
if (trackName != null && artistName != null) {
return state.findByTrackAndArtist(trackName, artistName);
}
return null;
}
Future<List<LocalLibraryItem>> search(String query) async {
if (query.isEmpty) return [];
final results = await _db.search(query);
return results.map((e) => LocalLibraryItem.fromJson(e)).toList();
}
Future<int> getCount() async {
return await _db.getCount();
}
}
final localLibraryProvider =
NotifierProvider<LocalLibraryNotifier, LocalLibraryState>(
LocalLibraryNotifier.new,
);
+77 -27
View File
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -8,9 +9,11 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 1;
const _spotifyClientSecretKey = 'spotify_client_secret';
class SettingsNotifier extends Notifier<AppSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@override
AppSettings build() {
@@ -25,11 +28,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = AppSettings.fromJson(jsonDecode(json));
await _runMigrations(prefs);
_applySpotifyCredentials();
LogBuffer.loggingEnabled = state.enableLogging;
}
await _loadSpotifyClientSecret(prefs);
_applySpotifyCredentials();
LogBuffer.loggingEnabled = state.enableLogging;
}
Future<void> _runMigrations(SharedPreferences prefs) async {
@@ -49,7 +54,40 @@ class SettingsNotifier extends Notifier<AppSettings> {
Future<void> _saveSettings() async {
final prefs = await _prefs;
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
final settingsToSave = state.copyWith(
spotifyClientSecret: '',
);
await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson()));
}
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
final storedSecret = await _secureStorage.read(key: _spotifyClientSecretKey);
final prefsSecret = state.spotifyClientSecret;
if ((storedSecret == null || storedSecret.isEmpty) &&
prefsSecret.isNotEmpty) {
await _secureStorage.write(key: _spotifyClientSecretKey, value: prefsSecret);
}
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
? storedSecret
: (prefsSecret.isNotEmpty ? prefsSecret : '');
if (effectiveSecret != state.spotifyClientSecret) {
state = state.copyWith(spotifyClientSecret: effectiveSecret);
}
if (prefsSecret.isNotEmpty) {
await _saveSettings();
}
}
Future<void> _storeSpotifyClientSecret(String secret) async {
if (secret.isEmpty) {
await _secureStorage.delete(key: _spotifyClientSecretKey);
} else {
await _secureStorage.write(key: _spotifyClientSecretKey, value: secret);
}
}
Future<void> _applySpotifyCredentials() async {
@@ -157,25 +195,28 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setSpotifyClientSecret(String clientSecret) {
Future<void> setSpotifyClientSecret(String clientSecret) async {
state = state.copyWith(spotifyClientSecret: clientSecret);
await _storeSpotifyClientSecret(clientSecret);
_saveSettings();
}
void setSpotifyCredentials(String clientId, String clientSecret) {
Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
state = state.copyWith(
spotifyClientId: clientId,
spotifyClientSecret: clientSecret,
);
await _storeSpotifyClientSecret(clientSecret);
_saveSettings();
_applySpotifyCredentials();
}
void clearSpotifyCredentials() {
Future<void> clearSpotifyCredentials() async {
state = state.copyWith(
spotifyClientId: '',
spotifyClientSecret: '',
);
await _storeSpotifyClientSecret('');
_saveSettings();
_applySpotifyCredentials();
}
@@ -231,31 +272,40 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setEnableLossyOption(bool enabled) {
state = state.copyWith(enableLossyOption: enabled);
// If Lossy is disabled and current quality is LOSSY, reset to LOSSLESS
if (!enabled && state.audioQuality == 'LOSSY') {
state = state.copyWith(audioQuality: 'LOSSLESS');
}
void setTidalHighFormat(String format) {
state = state.copyWith(tidalHighFormat: format);
_saveSettings();
}
void setLossyFormat(String format) {
state = state.copyWith(lossyFormat: format);
_saveSettings();
}
void setLossyBitrate(String bitrate) {
// Extract format from bitrate (e.g., 'mp3_320' -> 'mp3')
final format = bitrate.split('_').first;
state = state.copyWith(lossyBitrate: bitrate, lossyFormat: format);
_saveSettings();
}
void setUseAllFilesAccess(bool enabled) {
void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings();
}
void setAutoExportFailedDownloads(bool enabled) {
state = state.copyWith(autoExportFailedDownloads: enabled);
_saveSettings();
}
void setDownloadNetworkMode(String mode) {
state = state.copyWith(downloadNetworkMode: mode);
_saveSettings();
}
void setLocalLibraryEnabled(bool enabled) {
state = state.copyWith(localLibraryEnabled: enabled);
_saveSettings();
}
void setLocalLibraryPath(String path) {
state = state.copyWith(localLibraryPath: path);
_saveSettings();
}
void setLocalLibraryShowDuplicates(bool show) {
state = state.copyWith(localLibraryShowDuplicates: show);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+3 -10
View File
@@ -7,8 +7,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('StoreProvider');
final RegExp _leadingVersionPrefix = RegExp(r'^v');
/// Compare two semantic version strings
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
@@ -25,8 +23,8 @@ int compareVersions(String v1, String v2) {
return 0;
}
/// Extension categories
class StoreCategory {
static const String metadata = 'metadata';
static const String download = 'download';
static const String utility = 'utility';
@@ -111,13 +109,13 @@ class StoreExtension {
);
}
/// Check if this extension requires a higher app version than current
bool get requiresNewerApp {
if (minAppVersion == null || minAppVersion!.isEmpty) return false;
return compareVersions(minAppVersion!, AppInfo.version) > 0;
}
}
class StoreState {
final List<StoreExtension> extensions;
final String? selectedCategory;
@@ -164,7 +162,6 @@ class StoreState {
);
}
/// Get filtered extensions based on category and search
List<StoreExtension> get filteredExtensions {
var result = extensions;
@@ -186,13 +183,11 @@ class StoreState {
return result;
}
/// Count of extensions with updates available
int get updatesAvailableCount {
return extensions.where((e) => e.hasUpdate).length;
}
}
/// Provider for managing extension store
class StoreNotifier extends Notifier<StoreState> {
@override
StoreState build() {
@@ -215,7 +210,6 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
/// Refresh extensions from store
Future<void> refresh({bool forceRefresh = false}) async {
state = state.copyWith(isLoading: true, clearError: true);
@@ -240,7 +234,6 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
/// Set search query
void setSearchQuery(String query) {
state = state.copyWith(searchQuery: query);
}
@@ -249,7 +242,6 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(searchQuery: '', clearCategory: true);
}
/// Download and install extension
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
@@ -275,6 +267,7 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
Future<bool> updateExtension(String extensionId, String tempDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
+1 -10
View File
@@ -3,23 +3,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/theme_settings.dart';
/// Provider for theme settings state management
final themeProvider = NotifierProvider<ThemeNotifier, ThemeSettings>(() {
return ThemeNotifier();
});
/// Notifier for managing theme settings with persistence
class ThemeNotifier extends Notifier<ThemeSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@override
ThemeSettings build() {
// Load settings asynchronously on first access
_loadFromStorage();
return const ThemeSettings();
}
/// Load theme settings from SharedPreferences
Future<void> _loadFromStorage() async {
try {
final prefs = await _prefs;
@@ -39,7 +35,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
}
}
/// Save current settings to SharedPreferences
Future<void> _saveToStorage() async {
try {
final prefs = await _prefs;
@@ -52,13 +47,11 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
}
}
/// Set theme mode (light, dark, or system)
Future<void> setThemeMode(ThemeMode mode) async {
state = state.copyWith(themeMode: mode);
await _saveToStorage();
}
/// Enable or disable dynamic color from wallpaper
Future<void> setUseDynamicColor(bool value) async {
state = state.copyWith(useDynamicColor: value);
await _saveToStorage();
@@ -70,19 +63,16 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
await _saveToStorage();
}
/// Set seed color from int value
Future<void> setSeedColorValue(int colorValue) async {
state = state.copyWith(seedColorValue: colorValue);
await _saveToStorage();
}
/// Enable or disable AMOLED mode (pure black background)
Future<void> setUseAmoled(bool value) async {
state = state.copyWith(useAmoled: value);
await _saveToStorage();
}
/// Helper to convert string to ThemeMode
ThemeMode _themeModeFromString(String? value) {
if (value == null) return ThemeMode.system;
return ThemeMode.values.firstWhere(
@@ -91,3 +81,4 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
);
}
}
+161 -5
View File
@@ -193,11 +193,34 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// Step 1: Check for extension URL handlers first (handles YT Music, etc.)
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
final result = await PlatformBridge.handleURLWithExtension(url);
if (!_isRequestValid(requestId)) return;
// Retry logic for extension URL handlers (up to 3 attempts)
Map<String, dynamic>? result;
for (int attempt = 1; attempt <= 3; attempt++) {
result = await PlatformBridge.handleURLWithExtension(url);
if (!_isRequestValid(requestId)) return;
// Check if we got valid data
if (result != null && result['type'] == 'track' && result['track'] != null) {
final trackData = result['track'] as Map<String, dynamic>;
final name = trackData['name']?.toString() ?? '';
if (name.isNotEmpty) {
break;
}
} else if (result != null && (result['type'] == 'album' || result['type'] == 'playlist')) {
break;
} else if (result != null && result['type'] == 'artist') {
break;
}
if (attempt < 3) {
await Future.delayed(const Duration(milliseconds: 500));
}
}
if (result != null) {
final type = result['type'] as String?;
@@ -206,6 +229,15 @@ class TrackNotifier extends Notifier<TrackState> {
if (type == 'track' && result['track'] != null) {
final trackData = result['track'] as Map<String, dynamic>;
final track = _parseSearchTrack(trackData, source: extensionId);
if (track.name.isEmpty) {
state = TrackState(
isLoading: false,
error: 'Failed to load track metadata from extension',
);
return;
}
state = TrackState(
tracks: [track],
isLoading: false,
@@ -251,8 +283,131 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Step 2: Try Deezer URL parsing
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
_log.i('Detected Deezer URL, parsing...');
final parsed = await PlatformBridge.parseDeezerUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
final id = parsed['id'] as String;
final metadata = await PlatformBridge.getDeezerMetadata(type, id);
if (!_isRequestValid(requestId)) return;
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: id,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistInfo['name'] as String?,
coverUrl: playlistInfo['images'] as String?,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
artistAlbums: albums,
);
}
return;
}
// Step 3: Try Tidal URL parsing
if (url.contains('tidal.com')) {
_log.i('Detected Tidal URL, parsing...');
final parsed = await PlatformBridge.parseTidalUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
final id = parsed['id'] as String;
_log.i('Tidal URL parsed: type=$type, id=$id');
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
if (type == 'track') {
try {
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(url);
if (!_isRequestValid(requestId)) return;
final spotifyUrl = conversion['spotify_url'] as String?;
final deezerUrl = conversion['deezer_url'] as String?;
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(spotifyUrl);
if (!_isRequestValid(requestId)) return;
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
return;
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
final deezerParsed = await PlatformBridge.parseDeezerUrl(deezerUrl);
final metadata = await PlatformBridge.getDeezerMetadata('track', deezerParsed['id'] as String);
if (!_isRequestValid(requestId)) return;
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
return;
}
} catch (e) {
_log.w('Failed to convert Tidal URL via SongLink: $e');
}
}
// For album/artist/playlist, not yet supported
state = TrackState(
isLoading: false,
error: 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
hasSearchText: state.hasSearchText,
);
return;
}
// Step 4: Fall back to Spotify parsing
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return; // Request cancelled
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
@@ -264,7 +419,7 @@ class TrackNotifier extends Notifier<TrackState> {
rethrow;
}
if (!_isRequestValid(requestId)) return; // Request cancelled
if (!_isRequestValid(requestId)) return;
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
@@ -556,7 +711,8 @@ class TrackNotifier extends Notifier<TrackState> {
final tracks = List<Track>.from(state.tracks);
tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks);
} catch (e) {
} catch (_) {
// Silently ignore update failures - track may have been removed
}
}
+66 -26
View File
@@ -10,6 +10,7 @@ import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
@@ -44,10 +45,10 @@ class AlbumScreen extends ConsumerStatefulWidget {
final String albumId;
final String albumName;
final String? coverUrl;
final List<Track>? tracks; // Optional - will fetch if null
final String? extensionId; // If from extension
final String? artistId; // Artist ID for navigation
final String? artistName; // Artist name for navigation
final List<Track>? tracks;
final String? extensionId;
final String? artistId;
final String? artistName;
const AlbumScreen({
super.key,
@@ -80,7 +81,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
// Use extensionId if available, otherwise detect from albumId prefix
final providerId = widget.extensionId ??
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
@@ -90,10 +93,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
});
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
_artistId = widget.artistId; // Use provided artist ID if available
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
_tracks = widget.tracks;
} else {
_tracks = _AlbumCache.get(widget.albumId);
}
_artistId = widget.artistId;
if (_tracks == null) {
if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks();
}
@@ -114,7 +121,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
Future<void> _extractDominantColor() async {
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
@@ -123,21 +130,18 @@ Future<void> _extractDominantColor() async {
}
String _formatReleaseDate(String date) {
// Handle formats: "2024-01-15", "2024-01", "2024"
if (date.length >= 10) {
// Full date: 2024-01-15
final parts = date.substring(0, 10).split('-');
if (parts.length == 3) {
return '${parts[2]}/${parts[1]}/${parts[0]}'; // DD/MM/YYYY
return '${parts[2]}/${parts[1]}/${parts[0]}';
}
} else if (date.length >= 7) {
// Month: 2024-01
final parts = date.split('-');
if (parts.length >= 2) {
return '${parts[1]}/${parts[0]}'; // MM/YYYY
return '${parts[1]}/${parts[0]}';
}
}
return date; // Year only or unknown format
return date;
}
Future<void> _fetchTracks() async {
@@ -156,7 +160,6 @@ Future<void> _fetchTracks() async {
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
// Extract artist ID from album_info if available
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = albumInfo?['artist_id'] as String?;
@@ -228,7 +231,7 @@ Future<void> _fetchTracks() async {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
@@ -261,7 +264,6 @@ Future<void> _fetchTracks() async {
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
@@ -493,11 +495,9 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
}
void _navigateToArtist(BuildContext context, String artistName) {
// Use stored artist ID if available, otherwise use a placeholder
final artistId = _artistId ??
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
// Don't navigate if artist ID is unknown
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Artist information not available')),
@@ -505,7 +505,6 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
return;
}
// If from extension, use ExtensionArtistScreen
if (widget.extensionId != null) {
Navigator.push(
context,
@@ -613,6 +612,17 @@ class _AlbumTrackItem extends ConsumerWidget {
return state.isDownloaded(track.id);
}));
final settings = ref.watch(settingsProvider);
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(localLibraryProvider.select((state) =>
state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
)))
: false;
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
@@ -642,17 +652,46 @@ child: ListTile(
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
subtitle: Row(
children: [
Flexible(child: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant))),
if (isInLocalLibrary) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 3),
Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)),
],
),
),
],
],
),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
),
),
);
}
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
if (isQueued) return;
if (isInLocalLibrary) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))));
}
return;
}
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
@@ -677,6 +716,7 @@ child: ListTile(
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required bool isInLocalLibrary,
required double progress,
}) {
const double size = 44.0;
@@ -684,7 +724,7 @@ child: ListTile(
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
);
} else if (isFinalizing) {
+28 -23
View File
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
@@ -73,7 +74,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
final int? monthlyListeners;
final List<ArtistAlbum>? albums;
final List<Track>? topTracks;
final String? extensionId; // If set, skip fetching from Spotify/Deezer
final String? extensionId;
const ArtistScreen({
super.key,
@@ -102,7 +103,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
// Selection mode state
bool _isSelectionMode = false;
final Set<String> _selectedAlbumIds = {};
bool _isFetchingDiscography = false;
@@ -111,7 +111,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
void initState() {
super.initState();
// Setup scroll listener for sticky title
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -321,11 +320,9 @@ return PopScope(
if (compilations.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
],
// Add padding at bottom for selection bar
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
],
),
// Selection action bar
if (_isSelectionMode)
_buildSelectionBar(context, colorScheme, albums),
],
@@ -403,14 +400,12 @@ return PopScope(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Close button
IconButton(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
tooltip: context.l10n.dialogCancel,
),
const SizedBox(width: 8),
// Selection info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -432,13 +427,11 @@ return PopScope(
],
),
),
// Select all / Deselect button
TextButton(
onPressed: allSelected ? _deselectAll : () => _selectAll(allAlbums),
child: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
),
const SizedBox(width: 8),
// Download button
FilledButton.icon(
onPressed: selectedCount > 0 ? () => _downloadSelectedAlbums(context, selectedAlbums) : null,
icon: const Icon(Icons.download, size: 18),
@@ -472,7 +465,6 @@ return PopScope(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle bar
Container(
width: 40,
height: 4,
@@ -482,7 +474,6 @@ return PopScope(
borderRadius: BorderRadius.circular(2),
),
),
// Title
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Row(
@@ -576,7 +567,6 @@ return PopScope(
setState(() => _isFetchingDiscography = true);
// Show progress dialog
if (!mounted) {
setState(() => _isFetchingDiscography = false);
return;
@@ -598,7 +588,6 @@ return PopScope(
int fetchedCount = 0;
int failedCount = 0;
// Fetch tracks from each album
for (final album in albums) {
if (!_isFetchingDiscography) break; // Cancelled
@@ -619,12 +608,10 @@ return PopScope(
setState(() => _isFetchingDiscography = false);
// Close progress dialog
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
// Show warning if some albums failed
if (failedCount > 0 && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.discographyFailedToFetch)),
@@ -667,14 +654,12 @@ return PopScope(
return;
}
// Add to queue
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: qualityOverride,
);
// Show success message
if (mounted) {
final message = skippedCount > 0
? context.l10n.discographySkippedDownloaded(tracksToQueue.length, skippedCount)
@@ -697,14 +682,12 @@ return PopScope(
Future<List<Track>> _fetchAlbumTracks(ArtistAlbum album) async {
if (album.providerId != null && album.providerId!.isNotEmpty) {
// Extension album
final result = await PlatformBridge.getAlbumWithExtension(album.providerId!, album.id);
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
}
} else if (album.id.startsWith('deezer:')) {
// Deezer album
final deezerId = album.id.replaceFirst('deezer:', '');
final metadata = await PlatformBridge.getDeezerMetadata('album', deezerId);
if (metadata['tracks'] != null) {
@@ -712,7 +695,6 @@ return PopScope(
return tracksList.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album)).toList();
}
} else {
// Spotify album
final url = 'https://open.spotify.com/album/${album.id}';
final result = await PlatformBridge.handleURLWithExtension(url);
if (result != null && result['tracks'] != null) {
@@ -955,6 +937,18 @@ if (hasValidImage)
return state.isDownloaded(track.id);
}));
// Check local library for duplicate detection
final settings = ref.watch(settingsProvider);
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(localLibraryProvider.select((state) =>
state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
)))
: false;
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
@@ -964,7 +958,7 @@ if (hasValidImage)
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return InkWell(
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory),
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
@@ -1042,6 +1036,7 @@ if (hasValidImage)
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
progress: progress,
),
],
@@ -1051,9 +1046,18 @@ if (hasValidImage)
}
/// Handle tap on popular track item
void _handlePopularTrackTap(Track track, {required bool isQueued, required bool isInHistory}) async {
void _handlePopularTrackTap(Track track, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
if (isQueued) return;
if (isInLocalLibrary) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))),
);
}
return;
}
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
@@ -1082,6 +1086,7 @@ if (hasValidImage)
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required bool isInLocalLibrary,
required double progress,
}) {
const double size = 40.0;
@@ -1089,7 +1094,7 @@ if (hasValidImage)
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory),
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
child: Container(
width: size,
height: size,
+73 -35
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
@@ -49,19 +50,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
late final ProviderSubscription<TrackState> _trackStateSub;
late final ProviderSubscription<bool> _extensionInitSub;
/// Debounce timer for live search (extension-only feature)
Timer? _liveSearchDebounce;
/// Flag to prevent concurrent live search calls (prevents race conditions in extensions)
bool _isLiveSearchInProgress = false;
/// Pending query to execute after current search completes
String? _pendingLiveSearchQuery;
/// Minimum characters required to trigger live search
static const int _minLiveSearchChars = 3;
/// Debounce duration for live search
static const Duration _liveSearchDelay = Duration(milliseconds: 800);
List<DownloadHistoryItem>? _recentAccessHistoryCache;
@@ -512,7 +504,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Extension? currentSearchExtension;
List<SearchFilter> searchFilters = [];
// Check if using extension search provider
final isUsingExtensionSearch = currentSearchProvider != null &&
currentSearchProvider.isNotEmpty &&
extState.extensions.any((e) => e.id == currentSearchProvider && e.enabled);
@@ -640,8 +631,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
),
),
// Search filter bar (only shown when has search results or loading search)
if (searchFilters.isNotEmpty && (hasActualResults || isLoading))
// Search filter bar (only shown when has search results)
if (searchFilters.isNotEmpty && hasActualResults)
SliverToBoxAdapter(
child: _buildSearchFilterBar(
searchFilters,
@@ -1887,12 +1878,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
void _navigateToSearchAlbum(SearchAlbum album) {
ref.read(settingsProvider.notifier).setHasSearchedBefore();
// Extract the numeric ID from "deezer:123" format
String albumId = album.id;
if (albumId.startsWith('deezer:')) {
albumId = albumId.substring(7);
}
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
id: album.id,
name: album.name,
@@ -1901,9 +1886,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
providerId: 'deezer',
);
// Keep the full ID with prefix (e.g., "deezer:123") for AlbumScreen to detect source
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: albumId,
albumId: album.id,
albumName: album.name,
coverUrl: album.imageUrl,
tracks: const [], // Will be fetched by AlbumScreen
@@ -1914,12 +1900,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
void _navigateToSearchPlaylist(SearchPlaylist playlist) {
ref.read(settingsProvider.notifier).setHasSearchedBefore();
// Extract the numeric ID from "deezer:123" format
String playlistId = playlist.id;
if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7);
}
ref.read(recentAccessProvider.notifier).recordPlaylistAccess(
id: playlist.id,
name: playlist.name,
@@ -1928,12 +1908,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
providerId: 'deezer',
);
// Keep the full ID with prefix (e.g., "deezer:123") for PlaylistScreen to detect source
Navigator.push(context, MaterialPageRoute(
builder: (context) => PlaylistScreen(
playlistName: playlist.name,
coverUrl: playlist.imageUrl,
tracks: const [], // Will be fetched
playlistId: playlistId,
playlistId: playlist.id,
),
));
}
@@ -2427,6 +2408,18 @@ class _TrackItemWithStatus extends ConsumerWidget {
return state.isDownloaded(track.id);
}));
// Check local library for duplicate detection
final settings = ref.watch(settingsProvider);
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(localLibraryProvider.select((state) =>
state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
)))
: false;
double thumbWidth = 56;
double thumbHeight = 56;
@@ -2462,7 +2455,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
child: Padding(
@@ -2500,16 +2493,51 @@ class _TrackItemWithStatus extends ConsumerWidget {
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
track.artistName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
Row(
children: [
Flexible(
child: Text(
track.artistName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (isInLocalLibrary) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 3),
Text(
context.l10n.libraryInLibrary,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
),
),
],
),
),
],
],
),
],
),
),
_buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
_buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress),
],
),
),
@@ -2526,9 +2554,18 @@ class _TrackItemWithStatus extends ConsumerWidget {
);
}
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
if (isQueued) return;
if (isInLocalLibrary) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))),
);
}
return;
}
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
@@ -2555,6 +2592,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required bool isInLocalLibrary,
required double progress,
}) {
const double size = 44.0;
@@ -2562,7 +2600,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
child: Container(
width: size,
height: size,
+768
View File
@@ -0,0 +1,768 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
/// Screen to display tracks from a local library album
class LocalAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
final String? coverPath;
final List<LocalLibraryItem> tracks;
const LocalAlbumScreen({
super.key,
required this.albumName,
required this.artistName,
this.coverPath,
required this.tracks,
});
@override
ConsumerState<LocalAlbumScreen> createState() => _LocalAlbumScreenState();
}
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
late List<LocalLibraryItem> _sortedTracksCache;
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_rebuildTrackCaches();
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
if (cachedColor != null) {
_dominantColor = cachedColor;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_extractDominantColor();
});
}
@override
void didUpdateWidget(covariant LocalAlbumScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.tracks, widget.tracks) ||
oldWidget.tracks.length != widget.tracks.length) {
_rebuildTrackCaches();
}
if (oldWidget.coverPath != widget.coverPath) {
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
if (cachedColor != null && cachedColor != _dominantColor) {
_dominantColor = cachedColor;
}
_extractDominantColor();
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
Future<void> _extractDominantColor() async {
if (widget.coverPath == null || widget.coverPath!.isEmpty) return;
// Extract color from local file
final color = await PaletteService.instance.extractDominantColorFromFile(widget.coverPath!);
if (mounted && color != null && color != _dominantColor) {
setState(() {
_dominantColor = color;
});
}
}
List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
return a.trackName.compareTo(b.trackName);
});
return tracks;
}
void _rebuildTrackCaches() {
_sortedTracksCache = _buildSortedTracks();
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
}
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(List<LocalLibraryItem> tracks) {
final discMap = <int, List<LocalLibraryItem>>{};
for (final track in tracks) {
final discNumber = track.discNumber ?? 1;
discMap.putIfAbsent(discNumber, () => []).add(track);
}
return discMap;
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
_isSelectionMode = true;
_selectedIds.add(itemId);
});
}
void _exitSelectionMode() {
setState(() {
_isSelectionMode = false;
_selectedIds.clear();
});
}
void _toggleSelection(String itemId) {
setState(() {
if (_selectedIds.contains(itemId)) {
_selectedIds.remove(itemId);
if (_selectedIds.isEmpty) {
_isSelectionMode = false;
}
} else {
_selectedIds.add(itemId);
}
});
}
void _selectAll(List<LocalLibraryItem> tracks) {
setState(() {
_selectedIds.addAll(tracks.map((e) => e.id));
});
}
Future<void> _deleteSelected(List<LocalLibraryItem> currentTracks) async {
final count = _selectedIds.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogDelete),
),
],
),
);
if (confirmed == true && mounted) {
final libraryNotifier = ref.read(localLibraryProvider.notifier);
final idsToDelete = _selectedIds.toList();
int deletedCount = 0;
for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
if (item != null) {
try {
final file = File(item.filePath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
libraryNotifier.removeItem(id);
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
);
// Go back if all tracks were deleted
if (deletedCount == currentTracks.length) {
Navigator.pop(context);
}
}
}
}
Future<void> _openFile(String filePath) async {
try {
final mimeType = audioMimeTypeForPath(filePath);
await OpenFilex.open(filePath, type: mimeType);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final tracks = _sortedTracksCache;
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(
title: Text(widget.albumName),
),
body: const Center(
child: Text('No tracks found for this album'),
),
);
}
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _isSelectionMode = false);
});
}
return PopScope(
canPop: !_isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelectionMode) {
_exitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
),
],
),
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.albumName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
),
),
),
// Cover image centered
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverPath != null
? Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
cacheWidth: (coverSize * 2).toInt(),
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
);
},
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
),
const SizedBox(height: 4),
Text(
widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
Row(
children: [
// "Local" badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text('Local', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
const SizedBox(width: 8),
// Track count
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
const SizedBox(width: 8),
// Quality badge if all tracks have the same quality
if (_getCommonQuality(tracks) != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getCommonQuality(tracks)!,
style: TextStyle(
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
],
),
),
),
),
);
}
String? _getCommonQuality(List<LocalLibraryItem> tracks) {
if (tracks.isEmpty) return null;
final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null;
final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
for (final track in tracks) {
if (track.bitDepth != first.bitDepth || track.sampleRate != first.sampleRate) {
return null;
}
}
return firstQuality;
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
const Spacer(),
if (!_isSelectionMode)
TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
),
],
),
),
);
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
final discGroups = _discGroupsCache;
final hasMultipleDiscs = _hasMultipleDiscsCache;
final slivers = <Widget>[];
for (final discNumber in _sortedDiscNumbersCache) {
final discTracks = discGroups[discNumber]!;
if (hasMultipleDiscs) {
slivers.add(
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 6),
Text(
context.l10n.downloadedAlbumDiscHeader(discNumber),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
],
),
),
),
);
}
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackItem(context, colorScheme, discTracks[index]),
childCount: discTracks.length,
),
),
);
}
return SliverMainAxisGroup(slivers: slivers);
}
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, LocalLibraryItem track) {
final isSelected = _selectedIds.contains(track.id);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _openFile(track.filePath),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent,
shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
: null,
),
const SizedBox(width: 12),
],
SizedBox(
width: 24,
child: Text(
track.trackNumber?.toString() ?? '-',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
),
title: Text(
track.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Row(
children: [
Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (track.format != null) ...[
Text('', style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12)),
Text(
track.format!.toUpperCase(),
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12),
),
],
],
),
trailing: _isSelectionMode ? null : IconButton(
onPressed: () => _openFile(track.filePath),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
),
),
);
}
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks, double bottomPadding) {
final selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 12,
offset: const Offset(0, -4),
),
],
),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
Row(
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
TextButton.icon(
onPressed: () {
if (allSelected) {
_exitSelectionMode();
} else {
_selectAll(tracks);
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
),
],
),
),
),
);
}
}
+261 -41
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
@@ -61,18 +62,35 @@ class _MainShellState extends ConsumerState<MainShell> {
);
}
void _handleSharedUrl(String url) {
Navigator.of(context).popUntil((route) => route.isFirst);
Future<void> _handleSharedUrl(String url) async {
// Wait for extensions to be initialized before handling URL
final extState = ref.read(extensionProvider);
if (!extState.isInitialized) {
_log.d('Waiting for extensions to initialize before handling URL...');
// Wait up to 5 seconds for extensions to initialize
for (int i = 0; i < 50; i++) {
await Future.delayed(const Duration(milliseconds: 100));
if (!mounted) return;
if (ref.read(extensionProvider).isInitialized) {
_log.d('Extensions initialized, proceeding with URL handling');
break;
}
}
}
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
if (_currentIndex != 0) {
_onNavTap(0);
}
ref.read(trackProvider.notifier).fetchFromUrl(url);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.loadingSharedLink)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.loadingSharedLink)));
}
}
@@ -83,7 +101,9 @@ class _MainShellState extends ConsumerState<MainShell> {
final settings = ref.read(settingsProvider);
if (!settings.checkForUpdates) return;
final updateInfo = await UpdateChecker.checkForUpdate(channel: settings.updateChannel);
final updateInfo = await UpdateChecker.checkForUpdate(
channel: settings.updateChannel,
);
if (updateInfo != null && mounted) {
showUpdateDialog(
context,
@@ -104,6 +124,7 @@ class _MainShellState extends ConsumerState<MainShell> {
void _onNavTap(int index) {
if (_currentIndex != index) {
HapticFeedback.selectionClick();
setState(() => _currentIndex = index);
_pageController.animateToPage(
index,
@@ -122,35 +143,38 @@ class _MainShellState extends ConsumerState<MainShell> {
void _handleBackPress() {
final trackState = ref.read(trackProvider);
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
if (isKeyboardVisible) {
FocusManager.instance.primaryFocus?.unfocus();
return;
}
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
FocusManager.instance.primaryFocus?.unfocus();
return;
}
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
if (_currentIndex == 0 &&
!trackState.isLoading &&
(trackState.hasSearchText || trackState.hasContent)) {
ref.read(trackProvider.notifier).clear();
return;
}
if (_currentIndex != 0) {
_onNavTap(0);
return;
}
if (trackState.isLoading) {
return;
}
final now = DateTime.now();
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
if (_lastBackPress != null &&
now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
SystemNavigator.pop();
} else {
_lastBackPress = now;
@@ -166,19 +190,26 @@ class _MainShellState extends ConsumerState<MainShell> {
@override
Widget build(BuildContext context) {
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final queueState = ref.watch(
downloadQueueProvider.select((s) => s.queuedCount),
);
final trackState = ref.watch(trackProvider);
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount));
final showStore = ref.watch(
settingsProvider.select((s) => s.showExtensionStore),
);
final storeUpdatesCount = ref.watch(
storeProvider.select((s) => s.updatesAvailableCount),
);
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
final canPop = _currentIndex == 0 &&
!trackState.hasSearchText &&
!trackState.hasContent &&
!trackState.isLoading &&
!trackState.isShowingRecentAccess &&
!isKeyboardVisible;
final canPop =
_currentIndex == 0 &&
!trackState.hasSearchText &&
!trackState.hasContent &&
!trackState.isLoading &&
!trackState.isShowingRecentAccess &&
!isKeyboardVisible;
final tabs = <Widget>[
const HomeTab(),
@@ -195,21 +226,23 @@ class _MainShellState extends ConsumerState<MainShell> {
final destinations = <NavigationDestination>[
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
selectedIcon: BouncingIcon(child: const Icon(Icons.home)),
label: l10n.navHome,
),
NavigationDestination(
icon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.history_outlined),
child: const Icon(Icons.library_music_outlined),
),
selectedIcon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.history),
selectedIcon: SlidingIcon(
child: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.library_music),
),
),
label: l10n.navHistory,
label: l10n.navLibrary,
),
if (showStore)
NavigationDestination(
@@ -218,16 +251,18 @@ class _MainShellState extends ConsumerState<MainShell> {
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store_outlined),
),
selectedIcon: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store),
selectedIcon: SwingIcon(
child: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store),
),
),
label: l10n.navStore,
),
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
selectedIcon: SpinIcon(child: const Icon(Icons.settings)),
label: l10n.navSettings,
),
];
@@ -248,7 +283,7 @@ class _MainShellState extends ConsumerState<MainShell> {
if (didPop) {
return;
}
_handleBackPress();
},
child: Scaffold(
@@ -261,13 +296,198 @@ class _MainShellState extends ConsumerState<MainShell> {
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex),
onDestinationSelected: _onNavTap,
animationDuration: const Duration(milliseconds: 200),
animationDuration: const Duration(milliseconds: 500),
backgroundColor: Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
Theme.of(context).colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.03),
Theme.of(context).colorScheme.surface,
),
destinations: destinations,
),
),
);
}
}
class BouncingIcon extends StatefulWidget {
final Widget child;
const BouncingIcon({super.key, required this.child});
@override
State<BouncingIcon> createState() => _BouncingIconState();
}
class _BouncingIconState extends State<BouncingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 0.1,
end: 1.0,
).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut));
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(scale: _scaleAnimation, child: widget.child);
}
}
class SlidingIcon extends StatefulWidget {
final Widget child;
const SlidingIcon({super.key, required this.child});
@override
State<SlidingIcon> createState() => _SlidingIconState();
}
class _SlidingIconState extends State<SlidingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 350),
vsync: this,
);
_offsetAnimation = Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack));
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(position: _offsetAnimation, child: widget.child),
);
}
}
class SwingIcon extends StatefulWidget {
final Widget child;
const SwingIcon({super.key, required this.child});
@override
State<SwingIcon> createState() => _SwingIconState();
}
class _SwingIconState extends State<SwingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
// Create a swinging motion (like a pendulum/sign)
_rotationAnimation = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.0, end: -0.2), weight: 20),
TweenSequenceItem(tween: Tween(begin: -0.2, end: 0.15), weight: 20),
TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20),
TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20),
TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20),
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _rotationAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _rotationAnimation.value,
alignment: Alignment.topCenter,
child: child,
);
},
child: widget.child,
);
}
}
class SpinIcon extends StatefulWidget {
final Widget child;
const SpinIcon({super.key, required this.child});
@override
State<SpinIcon> createState() => _SpinIconState();
}
class _SpinIconState extends State<SpinIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 0.5,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack));
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RotationTransition(turns: _rotationAnimation, child: widget.child);
}
}
+58 -8
View File
@@ -10,13 +10,14 @@ import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
final String? coverUrl;
final List<Track> tracks;
final String? playlistId; // Deezer playlist ID for fetching tracks
final String? playlistId;
const PlaylistScreen({
super.key,
@@ -64,10 +65,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
});
try {
final result = await PlatformBridge.getDeezerMetadata('playlist', widget.playlistId!);
// Extract numeric ID from "deezer:123" format
String playlistId = widget.playlistId!;
if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7);
}
final result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
if (!mounted) return;
final trackList = result['tracks'] as List<dynamic>? ?? [];
// Go backend returns 'track_list' not 'tracks'
final trackList = result['track_list'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
setState(() {
@@ -429,6 +437,18 @@ class _PlaylistTrackItem extends ConsumerWidget {
return state.isDownloaded(track.id);
}));
// Check local library for duplicate detection
final settings = ref.watch(settingsProvider);
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(localLibraryProvider.select((state) =>
state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
)))
: false;
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
@@ -449,17 +469,46 @@ leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance))
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
subtitle: Row(
children: [
Flexible(child: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant))),
if (isInLocalLibrary) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 3),
Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)),
],
),
),
],
],
),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
),
),
);
}
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
if (isQueued) return;
if (isInLocalLibrary) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))));
}
return;
}
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
@@ -484,6 +533,7 @@ leading: track.coverUrl != null
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required bool isInLocalLibrary,
required double progress,
}) {
const double size = 44.0;
@@ -491,7 +541,7 @@ leading: track.coverUrl != null
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
);
} else if (isFinalizing) {
+1439 -279
View File
File diff suppressed because it is too large Load Diff
+235 -188
View File
@@ -94,6 +94,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final topPadding = MediaQuery.of(context).padding.top;
final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal';
return PopScope(
canPop: true,
@@ -173,24 +174,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
.read(settingsProvider.notifier)
.setAskQualityBeforeDownload(value),
),
SettingsSwitchItem(
icon: Icons.audiotrack,
title: context.l10n.enableLossyOption,
subtitle: settings.enableLossyOption
? context.l10n.enableLossyOptionSubtitleOn
: context.l10n.enableLossyOptionSubtitleOff,
value: settings.enableLossyOption,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setEnableLossyOption(value),
),
if (settings.enableLossyOption)
SettingsItem(
icon: Icons.tune,
title: context.l10n.lossyFormat,
subtitle: _getLossyBitrateLabel(settings.lossyBitrate),
onTap: () => _showLossyBitratePicker(context, ref, settings.lossyBitrate),
),
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
_QualityOption(
title: context.l10n.qualityFlacLossless,
@@ -215,18 +198,25 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES_LOSSLESS'),
showDivider: settings.enableLossyOption,
showDivider: isTidalService,
),
if (settings.enableLossyOption)
// Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus
if (isTidalService)
_QualityOption(
title: context.l10n.qualityLossy,
subtitle: settings.lossyFormat == 'opus'
? context.l10n.qualityLossyOpusSubtitle
: context.l10n.qualityLossyMp3Subtitle,
isSelected: settings.audioQuality == 'LOSSY',
title: 'Lossy 320kbps',
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
isSelected: settings.audioQuality == 'HIGH',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('LOSSY'),
.setAudioQuality('HIGH'),
showDivider: false,
),
if (isTidalService && settings.audioQuality == 'HIGH')
SettingsItem(
icon: Icons.tune,
title: 'Lossy Format',
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
onTap: () => _showTidalHighFormatPicker(context, ref, settings.tidalHighFormat),
showDivider: false,
),
],
@@ -344,6 +334,35 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
),
// Download Network Mode
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.wifi,
title: context.l10n.settingsDownloadNetwork,
subtitle: settings.downloadNetworkMode == 'wifi_only'
? context.l10n.settingsDownloadNetworkWifiOnly
: context.l10n.settingsDownloadNetworkAny,
onTap: () => _showNetworkModePicker(context, ref, settings.downloadNetworkMode),
),
SettingsSwitchItem(
icon: Icons.file_download_outlined,
title: context.l10n.settingsAutoExportFailed,
subtitle: context.l10n.settingsAutoExportFailedSubtitle,
value: settings.autoExportFailedDownloads,
onChanged: (value) {
ref.read(settingsProvider.notifier).setAutoExportFailedDownloads(value);
},
showDivider: false,
),
],
),
),
// All Files Access section (Android 13+ only)
if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
SliverToBoxAdapter(
@@ -395,7 +414,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
),
),
],
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
@@ -712,7 +731,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (ctx.mounted) Navigator.pop(ctx);
},
),
ListTile(
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: Text(context.l10n.setupChooseFromFiles),
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
@@ -721,6 +740,24 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
// iOS: Check if user selected iCloud Drive (not accessible by Go backend)
if (Platform.isIOS) {
final isICloudPath = result.contains('Mobile Documents') ||
result.contains('CloudDocs') ||
result.contains('com~apple~CloudDocs');
if (isICloudPath) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(context.l10n.setupIcloudNotSupported),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
}
ref
.read(settingsProvider.notifier)
.setDownloadDirectory(result);
@@ -858,28 +895,18 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
);
}
String _getLossyBitrateLabel(String bitrate) {
switch (bitrate) {
String _getTidalHighFormatLabel(String format) {
switch (format) {
case 'mp3_320':
return 'MP3 320kbps (Best)';
case 'mp3_256':
return 'MP3 256kbps';
case 'mp3_192':
return 'MP3 192kbps';
case 'mp3_128':
return 'MP3 128kbps';
return 'MP3 320kbps';
case 'opus_128':
return 'Opus 128kbps (Best)';
case 'opus_96':
return 'Opus 96kbps';
case 'opus_64':
return 'Opus 64kbps';
return 'Opus 128kbps';
default:
return 'MP3 320kbps';
}
}
void _showLossyBitratePicker(
void _showTidalHighFormatPicker(
BuildContext context,
WidgetRef ref,
String current,
@@ -888,130 +915,116 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.lossyFormat,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Lossy 320kbps Format',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.lossyFormatDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('MP3 320kbps'),
subtitle: const Text('Best compatibility, ~10MB per track'),
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Opus 128kbps'),
subtitle: const Text('Modern codec, ~4MB per track'),
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showNetworkModePicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.settingsDownloadNetwork,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.settingsDownloadNetworkSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
// MP3 Section
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 4),
child: Text(
'MP3',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('320kbps'),
subtitle: const Text('Best quality, larger files'),
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('256kbps'),
subtitle: const Text('High quality'),
trailing: current == 'mp3_256' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_256');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('192kbps'),
subtitle: const Text('Good quality'),
trailing: current == 'mp3_192' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_192');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('128kbps'),
subtitle: const Text('Smaller files'),
trailing: current == 'mp3_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_128');
Navigator.pop(context);
},
),
const Divider(indent: 24, endIndent: 24),
// Opus Section
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 4),
child: Text(
'Opus',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('128kbps'),
subtitle: const Text('Best quality, efficient codec'),
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('opus_128');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('96kbps'),
subtitle: const Text('Good quality'),
trailing: current == 'opus_96' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('opus_96');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('64kbps'),
subtitle: const Text('Smallest files'),
trailing: current == 'opus_64' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setLossyBitrate('opus_64');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
ListTile(
leading: const Icon(Icons.signal_cellular_alt),
title: Text(context.l10n.settingsDownloadNetworkAny),
subtitle: const Text('WiFi + Mobile Data'),
trailing: current == 'any' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setDownloadNetworkMode('any');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.wifi),
title: Text(context.l10n.settingsDownloadNetworkWifiOnly),
subtitle: const Text('Pause downloads on mobile data'),
trailing: current == 'wifi_only' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setDownloadNetworkMode('wifi_only');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
@@ -1153,7 +1166,8 @@ class _ServiceSelector extends ConsumerWidget {
icon: Icons.shopping_bag,
label: 'Amazon',
isSelected: effectiveService == 'amazon',
onTap: () => onChanged('amazon'),
isDisabled: true,
onTap: () {},
),
],
),
@@ -1190,11 +1204,15 @@ class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
final bool isDisabled;
final String? disabledReason;
const _ServiceChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
this.isDisabled = false,
this.disabledReason,
});
@override
@@ -1209,37 +1227,66 @@ class _ServiceChip extends StatelessWidget {
)
: colorScheme.surfaceContainerHigh;
final disabledColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.02),
colorScheme.surface,
)
: colorScheme.surfaceContainerLow;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
child: Tooltip(
message: isDisabled && disabledReason != null ? disabledReason! : '',
child: Material(
color: isDisabled
? disabledColor
: isSelected
? colorScheme.primaryContainer
: unselectedColor,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
child: InkWell(
onTap: isDisabled ? null : onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(
icon,
color: isDisabled
? colorScheme.onSurface.withValues(alpha: 0.38)
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected && !isDisabled
? FontWeight.w600
: FontWeight.normal,
color: isDisabled
? colorScheme.onSurface.withValues(alpha: 0.38)
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
if (isDisabled && disabledReason != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
disabledReason!,
style: TextStyle(
fontSize: 9,
color: colorScheme.onSurface.withValues(alpha: 0.38),
),
),
),
],
),
),
),
),
@@ -0,0 +1,676 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class LibrarySettingsPage extends ConsumerStatefulWidget {
const LibrarySettingsPage({super.key});
@override
ConsumerState<LibrarySettingsPage> createState() =>
_LibrarySettingsPageState();
}
class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
int _androidSdkVersion = 0;
bool _hasStoragePermission = false;
@override
void initState() {
super.initState();
_initDeviceInfo();
}
Future<void> _initDeviceInfo() async {
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
final sdkVersion = androidInfo.version.sdkInt;
// Check appropriate storage permission based on Android version
bool hasPermission;
if (sdkVersion >= 30) {
hasPermission = await Permission.manageExternalStorage.isGranted;
} else {
hasPermission = await Permission.storage.isGranted;
}
if (mounted) {
setState(() {
_androidSdkVersion = sdkVersion;
_hasStoragePermission = hasPermission;
});
}
} else if (Platform.isIOS) {
// iOS doesn't need explicit storage permission for app documents
setState(() => _hasStoragePermission = true);
}
}
Future<bool> _requestStoragePermission() async {
if (Platform.isIOS) return true;
PermissionStatus status;
if (_androidSdkVersion >= 30) {
status = await Permission.manageExternalStorage.request();
} else {
status = await Permission.storage.request();
}
if (status.isGranted) {
setState(() => _hasStoragePermission = true);
return true;
} else if (status.isPermanentlyDenied) {
if (mounted) {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.libraryStorageAccessRequired),
content: Text(context.l10n.libraryStorageAccessMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.setupOpenSettings),
),
],
),
);
if (shouldOpen == true) {
await openAppSettings();
}
}
}
return false;
}
Future<void> _pickLibraryFolder() async {
// Request permission first
if (!_hasStoragePermission) {
final granted = await _requestStoragePermission();
if (!granted) return;
}
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
}
Future<void> _startScan() async {
final settings = ref.read(settingsProvider);
final libraryPath = settings.localLibraryPath;
if (libraryPath.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.libraryScanSelectFolderFirst)),
);
return;
}
if (!await Directory(libraryPath).exists()) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.libraryFolderNotExist)),
);
}
return;
}
await ref.read(localLibraryProvider.notifier).startScan(libraryPath);
}
Future<void> _cancelScan() async {
await ref.read(localLibraryProvider.notifier).cancelScan();
}
Future<void> _clearLibrary() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.libraryClearConfirmTitle),
content: Text(context.l10n.libraryClearConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogClear),
),
],
),
);
if (confirmed == true) {
await ref.read(localLibraryProvider.notifier).clearLibrary();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.libraryCleared)));
}
}
}
Future<void> _cleanupMissingFiles() async {
final removed = await ref
.read(localLibraryProvider.notifier)
.cleanupMissingFiles();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.libraryRemovedMissingFiles(removed)),
),
);
}
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
final libraryState = ref.watch(localLibraryProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
context.l10n.libraryTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
SliverToBoxAdapter(
child: _LibraryHeroCard(
itemCount: libraryState.items.length,
isScanning: libraryState.isScanning,
scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile,
scanTotalFiles: libraryState.scanTotalFiles,
lastScannedAt: libraryState.lastScannedAt,
),
),
// Scan Settings Section
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.libraryScanSettings,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.library_music_outlined,
title: context.l10n.libraryEnableLocalLibrary,
subtitle: settings.localLibraryEnabled
? context.l10n.libraryEnableLocalLibrarySubtitle
: context.l10n.extensionsDisabled,
value: settings.localLibraryEnabled,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setLocalLibraryEnabled(value),
),
Opacity(
opacity: settings.localLibraryEnabled ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.folder_outlined,
title: context.l10n.libraryFolder,
subtitle: settings.localLibraryPath.isEmpty
? context.l10n.libraryFolderHint
: settings.localLibraryPath,
onTap: settings.localLibraryEnabled
? _pickLibraryFolder
: null,
),
),
SettingsSwitchItem(
icon: Icons.content_copy_outlined,
title: context.l10n.libraryShowDuplicateIndicator,
subtitle: settings.localLibraryShowDuplicates
? context.l10n.libraryShowDuplicateIndicatorSubtitle
: context.l10n.extensionsDisabled,
value: settings.localLibraryShowDuplicates,
enabled: settings.localLibraryEnabled,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setLocalLibraryShowDuplicates(value),
showDivider: false,
),
],
),
),
// Scan Actions Section
if (settings.localLibraryEnabled) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.libraryActions),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
if (libraryState.isScanning)
_ScanProgressTile(
progress: libraryState.scanProgress,
currentFile: libraryState.scanCurrentFile,
totalFiles: libraryState.scanTotalFiles,
onCancel: _cancelScan,
)
else
Opacity(
opacity: settings.localLibraryPath.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.refresh,
title: context.l10n.libraryScan,
subtitle: settings.localLibraryPath.isEmpty
? context.l10n.libraryScanSelectFolderFirst
: context.l10n.libraryScanSubtitle,
onTap: settings.localLibraryPath.isNotEmpty
? _startScan
: null,
),
),
Opacity(
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.cleaning_services_outlined,
title: context.l10n.libraryCleanupMissingFiles,
subtitle: context.l10n.libraryCleanupMissingFilesSubtitle,
onTap: libraryState.items.isNotEmpty
? _cleanupMissingFiles
: null,
),
),
Opacity(
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.delete_outline,
title: context.l10n.libraryClear,
subtitle: context.l10n.libraryClearSubtitle,
onTap: libraryState.items.isNotEmpty
? _clearLibrary
: null,
showDivider: false,
),
),
],
),
),
],
// Info Section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.libraryAbout,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 4),
Text(
context.l10n.libraryAboutDescription,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onPrimaryContainer
.withValues(alpha: 0.8),
),
),
],
),
),
],
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
}
class _LibraryHeroCard extends StatelessWidget {
final int itemCount;
final bool isScanning;
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
final DateTime? lastScannedAt;
const _LibraryHeroCard({
required this.itemCount,
required this.isScanning,
required this.scanProgress,
this.scanCurrentFile,
required this.scanTotalFiles,
this.lastScannedAt,
});
String _formatLastScanned(BuildContext context) {
if (lastScannedAt == null) return context.l10n.libraryLastScannedNever;
final now = DateTime.now();
final diff = now.difference(lastScannedAt!);
if (diff.inMinutes < 1) return context.l10n.timeJustNow;
if (diff.inHours < 1) return context.l10n.timeMinutesAgo(diff.inMinutes);
if (diff.inDays < 1) return context.l10n.timeHoursAgo(diff.inHours);
if (diff.inDays < 7) return context.l10n.dateDaysAgo(diff.inDays);
return '${lastScannedAt!.day}/${lastScannedAt!.month}/${lastScannedAt!.year}';
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
height: 220,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
boxShadow: [
if (!isDark)
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Background decorative elements
Positioned(
right: -20,
top: -20,
child: Icon(
Icons.library_music,
size: 200,
color: colorScheme.primary.withValues(alpha: 0.05),
),
),
Positioned(
left: -40,
bottom: -40,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
),
),
),
// Content
Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
isScanning ? Icons.sync : Icons.music_note,
color: colorScheme.onPrimaryContainer,
size: 32,
),
),
const Spacer(),
if (isScanning)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onPrimary,
),
),
const SizedBox(width: 8),
Text(
'Scanning...',
style: TextStyle(
color: colorScheme.onPrimary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const Spacer(),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
itemCount.toString(),
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
height: 1.0,
letterSpacing: -2,
),
),
),
const SizedBox(height: 4),
Text(
context.l10n
.libraryTracksCount(itemCount)
.replaceAll(itemCount.toString(), '')
.trim(), // Getting just the label part if possible, or just use the full string if not
style: TextStyle(
fontSize: 16,
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
if (isScanning && scanCurrentFile != null) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
value: scanProgress / 100,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
] else ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.history,
size: 14,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
const SizedBox(width: 6),
Text(
context.l10n.libraryLastScanned(
_formatLastScanned(context),
),
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
],
],
),
),
],
),
);
}
}
class _ScanProgressTile extends StatelessWidget {
final double progress;
final String? currentFile;
final int totalFiles;
final VoidCallback onCancel;
const _ScanProgressTile({
required this.progress,
this.currentFile,
required this.totalFiles,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.scanner, color: colorScheme.primary),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.libraryScanning,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
context.l10n.libraryScanProgress(
progress.toStringAsFixed(0),
totalFiles,
),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
TextButton(
onPressed: onCancel,
child: Text(context.l10n.actionCancel),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: progress / 100,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
if (currentFile != null) ...[
const SizedBox(height: 4),
Text(
currentFile!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}
}
+14 -12
View File
@@ -65,21 +65,23 @@ class _LogScreenState extends State<LogScreen> {
);
}
void _copyLogs() {
final logs = LogBuffer().export();
void _copyLogs() async {
final logs = await LogBuffer().exportWithDeviceInfo();
Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.logCopied),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
duration: const Duration(seconds: 2),
),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.logCopied),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
duration: const Duration(seconds: 2),
),
);
}
}
void _shareLogs() {
final logs = LogBuffer().export();
void _shareLogs() async {
final logs = await LogBuffer().exportWithDeviceInfo();
SharePlus.instance.share(ShareParams(text: logs, subject: 'SpotiFLAC Logs'));
}
+7
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
import 'package:spotiflac_android/screens/settings/library_settings_page.dart';
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
import 'package:spotiflac_android/screens/settings/about_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart';
@@ -73,6 +74,12 @@ class SettingsTab extends ConsumerWidget {
subtitle: l10n.settingsDownloadSubtitle,
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
),
SettingsItem(
icon: Icons.library_music_outlined,
title: l10n.settingsLocalLibrary,
subtitle: l10n.settingsLocalLibrarySubtitle,
onTap: () => _navigateTo(context, const LibrarySettingsPage()),
),
SettingsItem(
icon: Icons.tune_outlined,
title: l10n.settingsOptions,
+113 -66
View File
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -14,9 +15,11 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
class TrackMetadataScreen extends ConsumerStatefulWidget {
final DownloadHistoryItem item;
final DownloadHistoryItem? item;
final LocalLibraryItem? localItem;
const TrackMetadataScreen({super.key, required this.item});
const TrackMetadataScreen({super.key, this.item, this.localItem})
: assert(item != null || localItem != null, 'Either item or localItem must be provided');
@override
ConsumerState<TrackMetadataScreen> createState() => _TrackMetadataScreenState();
@@ -88,7 +91,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _extractDominantColor() async {
final coverUrl = widget.item.coverUrl;
// For local items with cover path, extract from file
if (_isLocalItem && _localCoverPath != null && _localCoverPath!.isNotEmpty) {
final color = await PaletteService.instance.extractDominantColorFromFile(_localCoverPath!);
if (mounted && color != null && color != _dominantColor) {
setState(() => _dominantColor = color);
}
return;
}
final coverUrl = _coverUrl;
if (coverUrl == null) return;
// Check cache first
final cachedColor = PaletteService.instance.getCached(coverUrl);
@@ -107,7 +120,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _checkFile() async {
var filePath = widget.item.filePath;
var filePath = _filePath;
if (filePath.startsWith('EXISTS:')) {
filePath = filePath.substring(7);
}
@@ -134,25 +147,38 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
DownloadHistoryItem get item => widget.item;
String get trackName => item.trackName;
String get artistName => item.artistName;
String get albumName => item.albumName;
String? get albumArtist => _normalizeOptionalString(item.albumArtist);
int? get trackNumber => item.trackNumber;
int? get discNumber => item.discNumber;
String? get releaseDate => item.releaseDate;
String? get isrc => item.isrc;
String? get genre => item.genre;
String? get label => item.label;
String? get copyright => item.copyright;
bool get _isLocalItem => widget.localItem != null;
DownloadHistoryItem? get _downloadItem => widget.item;
LocalLibraryItem? get _localLibraryItem => widget.localItem;
String get _itemId => _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id;
String get trackName => _isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName;
String get artistName => _isLocalItem ? _localLibraryItem!.artistName : _downloadItem!.artistName;
String get albumName => _isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName;
String? get albumArtist => _normalizeOptionalString(_isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist);
int? get trackNumber => _isLocalItem ? _localLibraryItem!.trackNumber : _downloadItem!.trackNumber;
int? get discNumber => _isLocalItem ? _localLibraryItem!.discNumber : _downloadItem!.discNumber;
String? get releaseDate => _isLocalItem ? _localLibraryItem!.releaseDate : _downloadItem!.releaseDate;
String? get isrc => _isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc;
String? get genre => _isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre;
String? get label => _isLocalItem ? null : _downloadItem!.label;
String? get copyright => _isLocalItem ? null : _downloadItem!.copyright;
int? get duration => _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration;
int? get bitDepth => _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
int? get sampleRate => _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
String get _filePath => _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
String? get _coverUrl => _isLocalItem ? null : _downloadItem!.coverUrl;
String? get _localCoverPath => _isLocalItem ? _localLibraryItem!.coverPath : null;
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
DateTime get _addedAt => _isLocalItem ? _localLibraryItem!.scannedAt : _downloadItem!.downloadedAt;
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
String get cleanFilePath {
final path = item.filePath;
final path = _filePath;
return path.startsWith('EXISTS:') ? path.substring(7) : path;
}
int? get bitDepth => item.bitDepth;
int? get sampleRate => item.sampleRate;
@override
Widget build(BuildContext context) {
@@ -287,7 +313,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Hero(
tag: 'cover_${item.id}',
tag: 'cover_$_itemId',
child: Container(
width: coverSize,
height: coverSize,
@@ -303,9 +329,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
child: _coverUrl != null
? CachedNetworkImage(
imageUrl: _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
@@ -318,14 +344,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
: _localCoverPath != null && _localCoverPath!.isNotEmpty
? Image.file(
File(_localCoverPath!),
fit: BoxFit.cover,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
@@ -448,11 +479,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_buildMetadataGrid(context, colorScheme),
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
if (_spotifyId != null && _spotifyId!.isNotEmpty) ...[
const SizedBox(height: 8),
Builder(
builder: (context) {
final isDeezer = item.spotifyId!.contains('deezer');
final isDeezer = _spotifyId!.contains('deezer');
return OutlinedButton.icon(
onPressed: () => _openServiceUrl(context),
icon: const Icon(Icons.open_in_new, size: 18),
@@ -474,10 +505,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _openServiceUrl(BuildContext context) async {
if (item.spotifyId == null) return;
if (_spotifyId == null) return;
final isDeezer = item.spotifyId!.contains('deezer');
final rawId = item.spotifyId!.replaceAll('deezer:', '');
final isDeezer = _spotifyId!.contains('deezer');
final rawId = _spotifyId!.replaceAll('deezer:', '');
final webUrl = isDeezer
? 'https://www.deezer.com/track/$rawId'
@@ -519,12 +550,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Determine audio quality string - prefer stored quality from download
String? audioQualityStr;
final fileName = item.filePath.split('/').last;
final fileName = _filePath.split('/').last;
final fileExt = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : '';
// Use stored quality from download history if available
if (item.quality != null && item.quality!.isNotEmpty) {
audioQualityStr = item.quality;
if (_quality != null && _quality!.isNotEmpty) {
audioQualityStr = _quality;
} else if (bitDepth != null && sampleRate != null) {
// Fallback for FLAC files without stored quality
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
@@ -550,8 +581,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
if (discNumber != null && discNumber! > 0)
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
if (item.duration != null)
_MetadataItem(context.l10n.trackDuration, _formatDuration(item.duration!)),
if (duration != null)
_MetadataItem(context.l10n.trackDuration, _formatDuration(duration!)),
if (audioQualityStr != null)
_MetadataItem(context.l10n.trackAudioQuality, audioQualityStr),
if (releaseDate != null && releaseDate!.isNotEmpty)
@@ -566,16 +597,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_MetadataItem('ISRC', isrc!),
];
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
final isDeezer = item.spotifyId!.contains('deezer');
final cleanId = item.spotifyId!.replaceAll('deezer:', '');
if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) {
final isDeezer = _spotifyId!.contains('deezer');
final cleanId = _spotifyId!.replaceAll('deezer:', '');
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId));
}
items.addAll([
_MetadataItem(context.l10n.trackMetadataService, item.service.toUpperCase()),
_MetadataItem(context.l10n.trackDownloaded, _formatFullDate(item.downloadedAt)),
]);
items.add(_MetadataItem(context.l10n.trackMetadataService, _service.toUpperCase()));
items.add(_MetadataItem(
context.l10n.trackDownloaded,
_formatFullDate(_addedAt),
));
return Column(
children: items.map((metadata) {
@@ -728,20 +760,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getServiceColor(item.service, colorScheme),
color: _getServiceColor(_service, colorScheme),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getServiceIcon(item.service),
_getServiceIcon(_service),
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
item.service.toUpperCase(),
_service.toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
@@ -943,14 +975,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
try {
// Convert duration from seconds to milliseconds
final durationMs = (item.duration ?? 0) * 1000;
final durationMs = (duration ?? 0) * 1000;
// First, check if lyrics are embedded in the file
if (_fileExists) {
final embeddedResult = await PlatformBridge.getLyricsLRC(
'',
item.trackName,
item.artistName,
trackName,
artistName,
filePath: cleanFilePath,
durationMs: 0,
).timeout(const Duration(seconds: 5), onTimeout: () => '');
@@ -971,9 +1003,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// No embedded lyrics, fetch from online
final result = await PlatformBridge.getLyricsLRC(
item.spotifyId ?? '',
item.trackName,
item.artistName,
_spotifyId ?? '',
trackName,
artistName,
filePath: null, // Don't check file again
durationMs: durationMs,
).timeout(
@@ -1177,17 +1209,32 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
TextButton(
onPressed: () async {
try {
final file = File(cleanFilePath);
if (await file.exists()) {
await file.delete();
if (_isLocalItem) {
// For local items, just delete the file
try {
final file = File(cleanFilePath);
if (await file.exists()) {
await file.delete();
}
} catch (e) {
debugPrint('Failed to delete file: $e');
}
} catch (e) {
debugPrint('Failed to delete file: $e');
// Also remove from local library database
// ref.read(localLibraryProvider.notifier).removeItem(_localLibraryItem!.id);
} else {
// Existing download history deletion logic
try {
final file = File(cleanFilePath);
if (await file.exists()) {
await file.delete();
}
} catch (e) {
debugPrint('Failed to delete file: $e');
}
ref.read(downloadHistoryProvider.notifier).removeFromHistory(_downloadItem!.id);
}
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
if (context.mounted) {
Navigator.pop(context);
Navigator.pop(context);
@@ -1242,7 +1289,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
await SharePlus.instance.share(
ShareParams(
files: [XFile(cleanFilePath)],
text: '${item.trackName} - ${item.artistName}',
text: '$trackName - $artistName',
),
);
}
-4
View File
@@ -27,7 +27,6 @@ class CoverCacheManager {
return _instance!;
}
/// Check if cache manager is initialized
static bool get isInitialized => _initialized && _instance != null;
static Future<void> initialize() async {
@@ -62,8 +61,6 @@ class CoverCacheManager {
}
}
/// Clear all cached cover images.
/// Returns the number of files deleted.
static Future<void> clearCache() async {
if (!_initialized || _instance == null) return;
await _instance!.emptyCache();
@@ -98,7 +95,6 @@ class CoverCacheManager {
}
}
/// Statistics about the cover image cache
class CacheStats {
final int fileCount;
final int totalSizeBytes;
+44 -46
View File
@@ -9,8 +9,6 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg');
/// FFmpeg service for audio conversion and remuxing
/// Uses ffmpeg_kit_flutter_new_audio plugin
class FFmpegService {
static Future<FFmpegResult> _execute(String command) async {
try {
@@ -48,6 +46,47 @@ class FFmpegService {
return null;
}
static Future<String?> convertM4aToLossy(
String inputPath, {
required String format,
String? bitrate,
bool deleteOriginal = true,
}) async {
String bitrateValue = format == 'opus' ? '128k' : '320k';
if (bitrate != null && bitrate.contains('_')) {
final parts = bitrate.split('_');
if (parts.length == 2) {
bitrateValue = '${parts[1]}k';
}
}
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = inputPath.replaceAll('.m4a', extension);
String command;
if (format == 'opus') {
command =
'-i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
} else {
command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y';
}
final result = await _execute(command);
if (result.success) {
if (deleteOriginal) {
try {
await File(inputPath).delete();
} catch (_) {}
}
return outputPath;
}
_log.e('M4A to $format conversion failed: ${result.output}');
return null;
}
static Future<String?> convertFlacToMp3(
String inputPath, {
String bitrate = '320k',
@@ -80,7 +119,6 @@ class FFmpegService {
}) async {
final outputPath = inputPath.replaceAll('.flac', '.opus');
// Opus in OGG container with VBR
final command =
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y';
@@ -99,17 +137,13 @@ class FFmpegService {
return null;
}
/// Convert FLAC to lossy format based on format parameter
/// format: 'mp3' or 'opus'
/// bitrate: e.g., 'mp3_320', 'opus_128' - extracts the kbps value
static Future<String?> convertFlacToLossy(
String inputPath, {
required String format,
String? bitrate,
bool deleteOriginal = true,
}) async {
// Extract bitrate value from format like 'mp3_320' -> '320k'
String bitrateValue = '320k'; // default for mp3
String bitrateValue = '320k';
if (bitrate != null && bitrate.contains('_')) {
final parts = bitrate.split('_');
if (parts.length == 2) {
@@ -338,8 +372,6 @@ class FFmpegService {
return null;
}
/// Embed metadata to Opus file
/// Uses METADATA_BLOCK_PICTURE tag for cover art (OGG/Vorbis standard)
static Future<String?> embedMetadataToOpus({
required String opusPath,
String? coverPath,
@@ -354,7 +386,6 @@ class FFmpegService {
cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-c:a copy ');
// Embed metadata tags (Vorbis comments)
if (metadata != null) {
metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
@@ -362,12 +393,10 @@ class FFmpegService {
});
}
// Embed cover art using METADATA_BLOCK_PICTURE
if (coverPath != null) {
try {
final pictureBlock = await _createMetadataBlockPicture(coverPath);
if (pictureBlock != null) {
// Escape special characters for shell
final escapedBlock = pictureBlock.replaceAll('"', '\\"');
cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ');
_log.d('Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)');
@@ -424,19 +453,6 @@ class FFmpegService {
return null;
}
/// Create METADATA_BLOCK_PICTURE base64 string for OGG/Opus cover art
/// Format follows FLAC picture block specification:
/// - 4 bytes: picture type (3 = front cover)
/// - 4 bytes: MIME type length
/// - n bytes: MIME type string
/// - 4 bytes: description length
/// - n bytes: description string
/// - 4 bytes: width
/// - 4 bytes: height
/// - 4 bytes: color depth
/// - 4 bytes: colors used (0 for non-indexed)
/// - 4 bytes: picture data length
/// - n bytes: picture data
static Future<String?> _createMetadataBlockPicture(String imagePath) async {
try {
final file = File(imagePath);
@@ -447,7 +463,6 @@ class FFmpegService {
final imageData = await file.readAsBytes();
// Detect MIME type from file extension or magic bytes
String mimeType;
if (imagePath.toLowerCase().endsWith('.png')) {
mimeType = 'image/png';
@@ -455,7 +470,6 @@ class FFmpegService {
imagePath.toLowerCase().endsWith('.jpeg')) {
mimeType = 'image/jpeg';
} else {
// Check magic bytes
if (imageData.length >= 8 &&
imageData[0] == 0x89 && imageData[1] == 0x50 &&
imageData[2] == 0x4E && imageData[3] == 0x47) {
@@ -464,75 +478,61 @@ class FFmpegService {
imageData[0] == 0xFF && imageData[1] == 0xD8) {
mimeType = 'image/jpeg';
} else {
mimeType = 'image/jpeg'; // Default to JPEG
mimeType = 'image/jpeg';
}
}
final mimeBytes = utf8.encode(mimeType);
const description = ''; // Empty description
const description = '';
final descBytes = utf8.encode(description);
// Build the FLAC picture block
// Total size: 4 + 4 + mimeLen + 4 + descLen + 4 + 4 + 4 + 4 + 4 + imageLen
final blockSize = 4 + 4 + mimeBytes.length + 4 + descBytes.length +
4 + 4 + 4 + 4 + 4 + imageData.length;
final buffer = ByteData(blockSize);
var offset = 0;
// Picture type: 3 = Front cover
buffer.setUint32(offset, 3, Endian.big);
offset += 4;
// MIME type length
buffer.setUint32(offset, mimeBytes.length, Endian.big);
offset += 4;
// MIME type string
final blockBytes = Uint8List(blockSize);
blockBytes.setRange(0, offset, buffer.buffer.asUint8List());
blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes);
offset += mimeBytes.length;
// Description length
final tempBuffer = ByteData(4);
tempBuffer.setUint32(0, descBytes.length, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
// Description string
blockBytes.setRange(offset, offset + descBytes.length, descBytes);
offset += descBytes.length;
// Width (0 = unknown)
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
// Height (0 = unknown)
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
// Color depth (0 = unknown)
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
// Colors used (0 for non-indexed)
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
// Picture data length
tempBuffer.setUint32(0, imageData.length, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
// Picture data
blockBytes.setRange(offset, offset + imageData.length, imageData);
// Base64 encode the entire block
final base64String = base64Encode(blockBytes);
return base64String;
@@ -549,7 +549,6 @@ class FFmpegService {
final key = entry.key.toUpperCase();
final value = entry.value;
// Map Vorbis comments to ID3v2 frame names
switch (key) {
case 'TITLE':
id3Map['title'] = value;
@@ -583,7 +582,6 @@ class FFmpegService {
id3Map['lyrics'] = value;
break;
default:
// Pass through other tags as-is
id3Map[key.toLowerCase()] = value;
}
}
-1
View File
@@ -139,7 +139,6 @@ class HistoryDatabase {
final prefs = await _prefs;
final lastContainer = prefs.getString('ios_last_container_path');
// Skip if container hasn't changed
if (lastContainer == _currentContainerPath) {
_log.d('iOS container path unchanged, skipping migration');
return false;
+386
View File
@@ -0,0 +1,386 @@
import 'dart:io';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LibraryDatabase');
class LocalLibraryItem {
final String id;
final String trackName;
final String artistName;
final String albumName;
final String? albumArtist;
final String filePath;
final String? coverPath;
final DateTime scannedAt;
final String? isrc;
final int? trackNumber;
final int? discNumber;
final int? duration;
final String? releaseDate;
final int? bitDepth;
final int? sampleRate;
final String? genre;
final String? format; // flac, mp3, opus, m4a
const LocalLibraryItem({
required this.id,
required this.trackName,
required this.artistName,
required this.albumName,
this.albumArtist,
required this.filePath,
this.coverPath,
required this.scannedAt,
this.isrc,
this.trackNumber,
this.discNumber,
this.duration,
this.releaseDate,
this.bitDepth,
this.sampleRate,
this.genre,
this.format,
});
Map<String, dynamic> toJson() => {
'id': id,
'trackName': trackName,
'artistName': artistName,
'albumName': albumName,
'albumArtist': albumArtist,
'filePath': filePath,
'coverPath': coverPath,
'scannedAt': scannedAt.toIso8601String(),
'isrc': isrc,
'trackNumber': trackNumber,
'discNumber': discNumber,
'duration': duration,
'releaseDate': releaseDate,
'bitDepth': bitDepth,
'sampleRate': sampleRate,
'genre': genre,
'format': format,
};
factory LocalLibraryItem.fromJson(Map<String, dynamic> json) =>
LocalLibraryItem(
id: json['id'] as String,
trackName: json['trackName'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
filePath: json['filePath'] as String,
coverPath: json['coverPath'] as String?,
scannedAt: DateTime.parse(json['scannedAt'] as String),
isrc: json['isrc'] as String?,
trackNumber: json['trackNumber'] as int?,
discNumber: json['discNumber'] as int?,
duration: json['duration'] as int?,
releaseDate: json['releaseDate'] as String?,
bitDepth: json['bitDepth'] as int?,
sampleRate: json['sampleRate'] as int?,
genre: json['genre'] as String?,
format: json['format'] as String?,
);
/// Create a unique key for matching tracks
String get matchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
String get albumKey => '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}';
}
class LibraryDatabase {
static final LibraryDatabase instance = LibraryDatabase._init();
static Database? _database;
LibraryDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('local_library.db');
return _database!;
}
Future<Database> _initDB(String fileName) async {
final dbPath = await getApplicationDocumentsDirectory();
final path = join(dbPath.path, fileName);
_log.i('Initializing library database at: $path');
return await openDatabase(
path,
version: 2, // Bumped version for cover_path migration
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
}
Future<void> _createDB(Database db, int version) async {
_log.i('Creating library database schema v$version');
await db.execute('''
CREATE TABLE library (
id TEXT PRIMARY KEY,
track_name TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT NOT NULL,
album_artist TEXT,
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
scanned_at TEXT NOT NULL,
isrc TEXT,
track_number INTEGER,
disc_number INTEGER,
duration INTEGER,
release_date TEXT,
bit_depth INTEGER,
sample_rate INTEGER,
genre TEXT,
format TEXT
)
''');
await db.execute('CREATE INDEX idx_library_isrc ON library(isrc)');
await db.execute('CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)');
await db.execute('CREATE INDEX idx_library_album ON library(album_name, album_artist)');
await db.execute('CREATE INDEX idx_library_file_path ON library(file_path)');
_log.i('Library database schema created with indexes');
}
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading library database from v$oldVersion to v$newVersion');
if (oldVersion < 2) {
// Add cover_path column
await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT');
_log.i('Added cover_path column');
}
}
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
return {
'id': json['id'],
'track_name': json['trackName'],
'artist_name': json['artistName'],
'album_name': json['albumName'],
'album_artist': json['albumArtist'],
'file_path': json['filePath'],
'cover_path': json['coverPath'],
'scanned_at': json['scannedAt'],
'isrc': json['isrc'],
'track_number': json['trackNumber'],
'disc_number': json['discNumber'],
'duration': json['duration'],
'release_date': json['releaseDate'],
'bit_depth': json['bitDepth'],
'sample_rate': json['sampleRate'],
'genre': json['genre'],
'format': json['format'],
};
}
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
return {
'id': row['id'],
'trackName': row['track_name'],
'artistName': row['artist_name'],
'albumName': row['album_name'],
'albumArtist': row['album_artist'],
'filePath': row['file_path'],
'coverPath': row['cover_path'],
'scannedAt': row['scanned_at'],
'isrc': row['isrc'],
'trackNumber': row['track_number'],
'discNumber': row['disc_number'],
'duration': row['duration'],
'releaseDate': row['release_date'],
'bitDepth': row['bit_depth'],
'sampleRate': row['sample_rate'],
'genre': row['genre'],
'format': row['format'],
};
}
// CRUD Operations
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
await db.insert(
'library',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
final db = await database;
final batch = db.batch();
for (final json in items) {
batch.insert(
'library',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
_log.i('Batch inserted ${items.length} items');
}
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database;
final rows = await db.query(
'library',
orderBy: 'album_artist, album_name, disc_number, track_number',
limit: limit,
offset: offset,
);
return rows.map(_dbRowToJson).toList();
}
Future<Map<String, dynamic>?> getById(String id) async {
final db = await database;
final rows = await db.query(
'library',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
final db = await database;
final rows = await db.query(
'library',
where: 'isrc = ?',
whereArgs: [isrc],
limit: 1,
);
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
Future<bool> existsByIsrc(String isrc) async {
final db = await database;
final result = await db.rawQuery(
'SELECT 1 FROM library WHERE isrc = ? LIMIT 1',
[isrc],
);
return result.isNotEmpty;
}
Future<List<Map<String, dynamic>>> findByTrackAndArtist(
String trackName,
String artistName,
) async {
final db = await database;
final rows = await db.query(
'library',
where: 'LOWER(track_name) = ? AND LOWER(artist_name) = ?',
whereArgs: [trackName.toLowerCase(), artistName.toLowerCase()],
);
return rows.map(_dbRowToJson).toList();
}
Future<Map<String, dynamic>?> findExisting({
String? isrc,
String? trackName,
String? artistName,
}) async {
// First try ISRC if available
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = await getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
}
// Then try name matching
if (trackName != null && artistName != null) {
final matches = await findByTrackAndArtist(trackName, artistName);
if (matches.isNotEmpty) return matches.first;
}
return null;
}
Future<Set<String>> getAllIsrcs() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""'
);
return rows.map((r) => r['isrc'] as String).toSet();
}
Future<Set<String>> getAllTrackKeys() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library'
);
return rows.map((r) => r['match_key'] as String).toSet();
}
Future<void> deleteByPath(String filePath) async {
final db = await database;
await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]);
}
Future<void> delete(String id) async {
final db = await database;
await db.delete('library', where: 'id = ?', whereArgs: [id]);
}
Future<int> cleanupMissingFiles() async {
final db = await database;
final rows = await db.query('library', columns: ['id', 'file_path']);
int removed = 0;
for (final row in rows) {
final filePath = row['file_path'] as String;
if (!await File(filePath).exists()) {
await db.delete('library', where: 'id = ?', whereArgs: [row['id']]);
removed++;
}
}
if (removed > 0) {
_log.i('Cleaned up $removed missing files from library');
}
return removed;
}
Future<void> clearAll() async {
final db = await database;
await db.delete('library');
_log.i('Cleared all library data');
}
Future<int> getCount() async {
final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM library');
return Sqflite.firstIntValue(result) ?? 0;
}
Future<List<Map<String, dynamic>>> search(String query, {int limit = 50}) async {
final db = await database;
final searchQuery = '%${query.toLowerCase()}%';
final rows = await db.query(
'library',
where: 'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?',
whereArgs: [searchQuery, searchQuery, searchQuery],
orderBy: 'track_name',
limit: limit,
);
return rows.map(_dbRowToJson).toList();
}
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
}
}
+11 -3
View File
@@ -145,12 +145,20 @@ class NotificationService {
required String artistName,
int? completedCount,
int? totalCount,
bool alreadyInLibrary = false,
}) async {
if (!_isInitialized) await initialize();
final title = completedCount != null && totalCount != null
? 'Download Complete ($completedCount/$totalCount)'
: 'Download Complete';
String title;
if (alreadyInLibrary) {
title = completedCount != null && totalCount != null
? 'Already in Library ($completedCount/$totalCount)'
: 'Already in Library';
} else {
title = completedCount != null && totalCount != null
? 'Download Complete ($completedCount/$totalCount)'
: 'Download Complete';
}
const androidDetails = AndroidNotificationDetails(
channelId,
+34 -3
View File
@@ -1,3 +1,4 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
@@ -8,7 +9,6 @@ class PaletteService {
static final PaletteService instance = PaletteService._();
PaletteService._();
/// Cache for already computed colors
final Map<String, Color> _colorCache = {};
/// Extract dominant color from a network image URL
@@ -46,12 +46,43 @@ class PaletteService {
}
}
/// Clear the color cache
Future<Color?> extractDominantColorFromFile(String? filePath) async {
if (filePath == null || filePath.isEmpty) return null;
final cached = _colorCache[filePath];
if (cached != null) {
return cached;
}
try {
final file = File(filePath);
if (!await file.exists()) return null;
final paletteGenerator = await PaletteGenerator.fromImageProvider(
FileImage(file),
size: const Size(64, 64),
maximumColorCount: 8,
);
final color = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (color != null) {
_colorCache[filePath] = color;
}
return color;
} catch (e) {
debugPrint('PaletteService file error: $e');
return null;
}
}
void clearCache() {
_colorCache.clear();
}
/// Get cached color without computing
Color? getCached(String? imageUrl) {
if (imageUrl == null) return null;
return _colorCache[imageUrl];
+60 -6
View File
@@ -323,7 +323,6 @@ class PlatformBridge {
});
}
/// Returns true if credentials are available (custom or env vars)
static Future<bool> hasSpotifyCredentials() async {
final result = await _channel.invokeMethod('hasSpotifyCredentials');
return result as bool;
@@ -369,6 +368,16 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> parseTidalUrl(String url) async {
final result = await _channel.invokeMethod('parseTidalUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> convertTidalToSpotifyDeezer(String tidalUrl) async {
final result = await _channel.invokeMethod('convertTidalToSpotifyDeezer', {'url': tidalUrl});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc});
return jsonDecode(result as String) as Map<String, dynamic>;
@@ -410,7 +419,6 @@ class PlatformBridge {
return logs.map((e) => e as Map<String, dynamic>).toList();
}
/// Get logs since a specific index (for incremental updates)
static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
final result = await _channel.invokeMethod('getLogsSince', {'index': index});
return jsonDecode(result as String) as Map<String, dynamic>;
@@ -561,7 +569,7 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<Map<String, dynamic>> downloadWithExtensions({
static Future<Map<String, dynamic>> downloadWithExtensions({
required String isrc,
required String spotifyId,
required String trackName,
@@ -584,8 +592,9 @@ class PlatformBridge {
String? genre,
String? label,
String lyricsMode = 'embed',
String? preferredService,
}) async {
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}');
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}${preferredService != null ? ' (service: $preferredService)' : ''}');
final request = jsonEncode({
'isrc': isrc,
'spotify_id': spotifyId,
@@ -609,6 +618,7 @@ class PlatformBridge {
'genre': genre ?? '',
'label': label ?? '',
'lyrics_mode': lyricsMode,
'service': preferredService ?? '',
});
final result = await _channel.invokeMethod('downloadWithExtensions', request);
@@ -795,7 +805,6 @@ class PlatformBridge {
}
}
/// Get extension home feed
static Future<Map<String, dynamic>?> getExtensionHomeFeed(String extensionId) async {
try {
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
@@ -809,7 +818,6 @@ class PlatformBridge {
}
}
/// Get extension browse categories
static Future<Map<String, dynamic>?> getExtensionBrowseCategories(String extensionId) async {
try {
final result = await _channel.invokeMethod('getExtensionBrowseCategories', {
@@ -823,6 +831,52 @@ class PlatformBridge {
}
}
// ==================== LOCAL LIBRARY SCANNING ====================
/// Set the directory for caching extracted cover art
static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
_log.i('setLibraryCoverCacheDir: $cacheDir');
await _channel.invokeMethod('setLibraryCoverCacheDir', {
'cache_dir': cacheDir,
});
}
/// Scan a folder for audio files and read their metadata
/// Returns a list of track metadata
static Future<List<Map<String, dynamic>>> scanLibraryFolder(String folderPath) async {
_log.i('scanLibraryFolder: $folderPath');
final result = await _channel.invokeMethod('scanLibraryFolder', {
'folder_path': folderPath,
});
final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
/// Get current library scan progress
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
final result = await _channel.invokeMethod('getLibraryScanProgress');
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Cancel ongoing library scan
static Future<void> cancelLibraryScan() async {
await _channel.invokeMethod('cancelLibraryScan');
}
/// Read metadata from a single audio file
static Future<Map<String, dynamic>?> readAudioMetadata(String filePath) async {
try {
final result = await _channel.invokeMethod('readAudioMetadata', {
'file_path': filePath,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.w('Failed to read audio metadata: $e');
return null;
}
}
static Future<Map<String, dynamic>> runPostProcessing(
String filePath, {
+56 -15
View File
@@ -9,16 +9,35 @@ class ShareIntentService {
factory ShareIntentService() => _instance;
ShareIntentService._internal();
// Spotify patterns
static final RegExp _spotifyUriPattern =
RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+');
static final RegExp _spotifyUrlPattern = RegExp(
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
);
// Deezer patterns
static final RegExp _deezerUrlPattern = RegExp(
r'https?://(www\.)?deezer\.com/(track|album|playlist|artist)/\d+(\?[^\s]*)?',
);
static final RegExp _deezerShortLinkPattern = RegExp(
r'https?://deezer\.page\.link/[a-zA-Z0-9]+',
);
// Tidal patterns
static final RegExp _tidalUrlPattern = RegExp(
r'https?://(listen\.)?tidal\.com/(track|album|playlist|artist)/[a-zA-Z0-9-]+(\?[^\s]*)?',
);
// YouTube Music patterns
static final RegExp _ytMusicUrlPattern = RegExp(
r'https?://music\.youtube\.com/(watch\?v=|playlist\?list=|channel/)[a-zA-Z0-9_-]+(\&[^\s]*)?',
);
final _sharedUrlController = StreamController<String>.broadcast();
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
bool _initialized = false;
String? _pendingUrl; // Store URL received before listener is ready
String? _pendingUrl;
Stream<String> get sharedUrlStream => _sharedUrlController.stream;
@@ -46,33 +65,55 @@ class ShareIntentService {
void _handleSharedMedia(List<SharedMediaFile> files, {bool isInitial = false}) {
for (final file in files) {
final textToCheck = file.path;
// Check both path and message - apps may share URL in either field
final textsToCheck = [
file.path,
if (file.message != null) file.message!,
];
final url = _extractSpotifyUrl(textToCheck);
if (url != null) {
_log.i('Received Spotify URL: $url (initial: $isInitial)');
if (isInitial) {
_pendingUrl = url;
for (final textToCheck in textsToCheck) {
final url = _extractMusicUrl(textToCheck);
if (url != null) {
_log.i('Received music URL: $url (initial: $isInitial)');
if (isInitial) {
_pendingUrl = url;
}
_sharedUrlController.add(url);
return;
}
_sharedUrlController.add(url);
return; // Only process first valid URL
}
}
}
String? _extractSpotifyUrl(String text) {
String? _extractMusicUrl(String text) {
if (text.isEmpty) return null;
// Try Spotify URI first
final uriMatch = _spotifyUriPattern.firstMatch(text);
if (uriMatch != null) {
return uriMatch.group(0);
}
final urlMatch = _spotifyUrlPattern.firstMatch(text);
if (urlMatch != null) {
final fullUrl = urlMatch.group(0)!;
final queryIndex = fullUrl.indexOf('?');
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
// Try all URL patterns
final patterns = [
_spotifyUrlPattern,
_deezerUrlPattern,
_deezerShortLinkPattern,
_tidalUrlPattern,
_ytMusicUrlPattern,
];
for (final pattern in patterns) {
final match = pattern.firstMatch(text);
if (match != null) {
final fullUrl = match.group(0)!;
// Remove query params for cleaner URL (except for YT Music which needs them)
if (pattern == _ytMusicUrlPattern) {
return fullUrl;
}
final queryIndex = fullUrl.indexOf('?');
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
}
}
return null;
+94 -9
View File
@@ -1,7 +1,10 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class LogEntry {
@@ -10,7 +13,7 @@ class LogEntry {
final String tag;
final String message;
final String? error;
final bool isFromGo; // Track if this log came from Go backend
final bool isFromGo;
LogEntry({
required this.timestamp,
@@ -47,8 +50,6 @@ class LogBuffer extends ChangeNotifier {
Timer? _goLogTimer;
int _lastGoLogIndex = 0;
/// Whether logging is enabled (controlled by settings)
/// User must enable "Detailed Logging" in settings to capture logs
static bool _loggingEnabled = false;
static bool get loggingEnabled => _loggingEnabled;
static set loggingEnabled(bool value) {
@@ -64,7 +65,6 @@ class LogBuffer extends ChangeNotifier {
int get length => _entries.length;
void add(LogEntry entry) {
// Skip adding if logging is disabled (except for errors which are always logged)
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
return;
}
@@ -76,7 +76,6 @@ class LogBuffer extends ChangeNotifier {
notifyListeners();
}
/// Start polling Go backend logs
void startGoLogPolling() {
_goLogTimer?.cancel();
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
@@ -84,13 +83,11 @@ class LogBuffer extends ChangeNotifier {
});
}
/// Stop polling Go backend logs
void stopGoLogPolling() {
_goLogTimer?.cancel();
_goLogTimer = null;
}
/// Fetch logs from Go backend since last index
Future<void> _fetchGoLogs() async {
try {
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
@@ -103,7 +100,6 @@ class LogBuffer extends ChangeNotifier {
final tag = log['tag'] as String? ?? 'Go';
final message = log['message'] as String? ?? '';
// Parse timestamp (format: "15:04:05.000")
DateTime parsedTime = DateTime.now();
if (timestamp.isNotEmpty) {
try {
@@ -158,6 +154,96 @@ class LogBuffer extends ChangeNotifier {
return buffer.toString();
}
Future<String> exportWithDeviceInfo() async {
final buffer = StringBuffer();
buffer.writeln('=' * 60);
buffer.writeln('SPOTIFLAC LOG EXPORT');
buffer.writeln('=' * 60);
buffer.writeln();
buffer.writeln('--- App Information ---');
buffer.writeln('App Version: ${AppInfo.version} (Build ${AppInfo.buildNumber})');
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
buffer.writeln();
buffer.writeln('--- Device Information ---');
try {
final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo;
buffer.writeln('Platform: Android');
buffer.writeln('Device: ${android.manufacturer} ${android.model}');
buffer.writeln('Brand: ${android.brand}');
buffer.writeln('Android Version: ${android.version.release} (SDK ${android.version.sdkInt})');
buffer.writeln('Device ID: ${android.id}');
buffer.writeln('Hardware: ${android.hardware}');
buffer.writeln('Product: ${android.product}');
buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}');
buffer.writeln('Is Physical Device: ${android.isPhysicalDevice}');
} else if (Platform.isIOS) {
final ios = await deviceInfo.iosInfo;
buffer.writeln('Platform: iOS');
buffer.writeln('Device: ${ios.utsname.machine}');
buffer.writeln('Model: ${ios.model}');
buffer.writeln('System Name: ${ios.systemName}');
buffer.writeln('System Version: ${ios.systemVersion}');
buffer.writeln('Device Name: ${ios.name}');
buffer.writeln('Is Physical Device: ${ios.isPhysicalDevice}');
}
} catch (e) {
buffer.writeln('Failed to get device info: $e');
}
buffer.writeln();
buffer.writeln('--- Log Summary ---');
buffer.writeln('Total Entries: ${_entries.length}');
int errorCount = 0;
int warnCount = 0;
int infoCount = 0;
int debugCount = 0;
int goCount = 0;
for (final entry in _entries) {
switch (entry.level) {
case 'ERROR':
case 'FATAL':
errorCount++;
break;
case 'WARN':
warnCount++;
break;
case 'INFO':
infoCount++;
break;
case 'DEBUG':
debugCount++;
break;
}
if (entry.isFromGo) goCount++;
}
buffer.writeln('Errors: $errorCount');
buffer.writeln('Warnings: $warnCount');
buffer.writeln('Info: $infoCount');
buffer.writeln('Debug: $debugCount');
buffer.writeln('From Go Backend: $goCount');
buffer.writeln();
buffer.writeln('=' * 60);
buffer.writeln('LOG ENTRIES');
buffer.writeln('=' * 60);
buffer.writeln();
for (final entry in _entries) {
buffer.writeln(entry.toString());
}
return buffer.toString();
}
List<LogEntry> filter({String? level, String? tag, String? search}) {
final tagLower = tag?.toLowerCase();
final searchLower = search?.toLowerCase();
@@ -221,7 +307,6 @@ class BufferedOutput extends LogOutput {
}
}
/// Global logger instance for the app
final log = Logger(
printer: PrettyPrinter(
methodCount: 0,
+140 -32
View File
@@ -10,11 +10,15 @@ class BuiltInService {
final String id;
final String label;
final List<QualityOption> qualityOptions;
final bool isDisabled; // If true, service is grayed out (fallback only)
final String? disabledReason;
const BuiltInService({
required this.id,
required this.label,
required this.qualityOptions,
this.isDisabled = false,
this.disabledReason,
});
}
@@ -27,6 +31,7 @@ const _builtInServices = [
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
QualityOption(id: 'HIGH', label: 'Lossy 320kbps', description: 'MP3 or Opus (smaller files)'),
],
),
BuiltInService(
@@ -46,16 +51,11 @@ const _builtInServices = [
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
],
isDisabled: true,
disabledReason: 'Fallback only',
),
];
/// Lossy quality option (shown when enabled in settings)
const _lossyQualityOption = QualityOption(
id: 'LOSSY',
label: 'Lossy',
description: 'MP3 320kbps or Opus 128kbps',
);
/// A reusable widget for selecting download service (built-in + extensions)
class DownloadServicePicker extends ConsumerStatefulWidget {
final String? trackName;
@@ -112,34 +112,21 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
/// Get quality options for the selected service
List<QualityOption> _getQualityOptions() {
final settings = ref.read(settingsProvider);
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
if (builtIn != null) {
// Add Lossy option if enabled in settings
if (settings.enableLossyOption) {
return [...builtIn.qualityOptions, _lossyQualityOption];
}
return builtIn.qualityOptions;
}
final extensionState = ref.read(extensionProvider);
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
if (ext != null && ext.qualityOptions.isNotEmpty) {
// Add Lossy option for extensions too if enabled
if (settings.enableLossyOption) {
return [...ext.qualityOptions, _lossyQualityOption];
}
return ext.qualityOptions;
}
// Default fallback options
final defaultOptions = [
return [
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
];
if (settings.enableLossyOption) {
return [...defaultOptions, _lossyQualityOption];
}
return defaultOptions;
}
@override
@@ -188,7 +175,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
),
),
Padding(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Wrap(
spacing: 8,
@@ -196,9 +183,14 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
children: [
for (final service in _builtInServices)
_ServiceChip(
label: service.label,
label: service.isDisabled
? '${service.label} (${service.disabledReason})'
: service.label,
isSelected: _selectedService == service.id,
onTap: () => setState(() => _selectedService = service.id),
isDisabled: service.isDisabled,
onTap: service.isDisabled
? null
: () => setState(() => _selectedService = service.id),
),
for (final ext in downloadExtensions)
_ServiceChip(
@@ -237,8 +229,13 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
subtitle: quality.description ?? '',
icon: _getQualityIcon(quality.id),
onTap: () {
Navigator.pop(context);
widget.onSelect(quality.id, _selectedService);
// For Tidal HIGH quality, show format picker first
if (_selectedService == 'tidal' && quality.id == 'HIGH') {
_showLossyFormatPicker(context);
} else {
Navigator.pop(context);
widget.onSelect(quality.id, _selectedService);
}
},
),
@@ -257,9 +254,10 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return Icons.high_quality;
case 'LOSSLESS':
return Icons.music_note;
case 'HIGH':
return Icons.aod;
case 'MP3_320':
case 'MP3':
case 'LOSSY':
return Icons.audiotrack;
case 'OPUS':
case 'OPUS_128':
@@ -268,6 +266,102 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return Icons.music_note;
}
}
void _showLossyFormatPicker(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
final currentFormat = settings.tidalHighFormat;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (modalContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
'Select Lossy Format',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose output format for 320kbps lossy download',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.audiotrack, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('MP3 320kbps'),
subtitle: const Text('Best compatibility, ~10MB per track'),
trailing: currentFormat == 'mp3_320'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('Opus 128kbps'),
subtitle: const Text('Modern codec, ~4MB per track'),
trailing: currentFormat == 'opus_128'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
const SizedBox(height: 16),
],
),
),
);
}
}
@@ -309,26 +403,32 @@ class _QualityOption extends StatelessWidget {
class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
final VoidCallback? onTap;
final String? iconPath;
final bool isDisabled;
const _ServiceChip({
required this.label,
required this.isSelected,
required this.onTap,
this.iconPath,
this.isDisabled = false,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
onTap: isDisabled ? null : onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
color: isDisabled
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
@@ -346,7 +446,11 @@ class _ServiceChip extends StatelessWidget {
errorBuilder: (context, error, stackTrace) => Icon(
Icons.extension,
size: 18,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
color: isDisabled
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
),
@@ -356,7 +460,11 @@ class _ServiceChip extends StatelessWidget {
label,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
color: isDisabled
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
+3 -3
View File
@@ -43,8 +43,8 @@ class SettingsGroup extends StatelessWidget {
}
}
/// A single settings item that can be used inside SettingsGroup
class SettingsItem extends StatelessWidget {
final IconData? icon;
final String title;
final String? subtitle;
@@ -125,8 +125,8 @@ class SettingsItem extends StatelessWidget {
}
}
/// A switch settings item for SettingsGroup
class SettingsSwitchItem extends StatelessWidget {
final IconData? icon;
final String title;
final String? subtitle;
@@ -213,8 +213,8 @@ class SettingsSwitchItem extends StatelessWidget {
}
}
/// Section header for settings groups
class SettingsSectionHeader extends StatelessWidget {
final String title;
const SettingsSectionHeader({super.key, required this.title});
+74 -2
View File
@@ -185,6 +185,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
url: "https://pub.dev"
source: hosted
version: "6.1.5"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
convert:
dependency: transitive
description:
@@ -419,6 +435,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.dev"
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.dev"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_svg:
dependency: "direct main"
description:
@@ -529,10 +593,10 @@ packages:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.7.2"
version: "0.6.7"
json_annotation:
dependency: "direct main"
description:
@@ -637,6 +701,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.6.1"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
node_preamble:
dependency: transitive
description:
+4 -2
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.3.0+67
version: 3.4.0+72
environment:
sdk: ^3.10.0
@@ -24,13 +24,15 @@ dependencies:
# Storage & Persistence
shared_preferences: ^2.5.3
flutter_secure_storage: ^9.2.2
path_provider: ^2.1.5
path: ^1.9.0
sqflite: ^2.4.1
# HTTP & Network
# HTTP & Network
http: ^1.6.0
dio: ^5.8.0
connectivity_plus: ^6.0.3
# UI Components
cupertino_icons: ^1.0.8