Compare commits

...

90 Commits

Author SHA1 Message Date
zarzet be9444c76b fix: revert material_color_utilities to ^0.11.1 (pinned by Flutter SDK) 2026-01-17 05:41:33 +07:00
zarzet cedb32904e fix(ios): add localization support for iOS build
- Add flutter_localizations and intl to pubspec_ios.yaml

- Add generate: true flag for l10n code generation

- Add CFBundleLocalizations to Info.plist with all supported languages

- Update http to ^1.6.0 and material_color_utilities to ^0.13.0

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

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

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

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

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

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

Changes:
- Deezer convertTrack now includes album_type from record_type
- Track creation preserves albumType and source throughout download flow
- Go exports include provider_id in album/artist responses
- Version bump to 3.1.0+59
2026-01-15 18:54:01 +07:00
zarzet 82440affac feat: add home tab enhancements, download queue improvements, and platform bridge updates 2026-01-15 04:31:33 +07:00
zarzet 6d2f75c5dc docs: update screenshots and VirusTotal link for v3.0.0 2026-01-14 21:42:16 +07:00
zarzet 18bc079632 Merge dev into main: v3.0.0 stable release 2026-01-14 18:08:30 +07:00
zarzet 4091a9c499 release: v3.0.0 stable with Extension System 2026-01-14 01:57:30 +07:00
zarzet 9346f2d149 fix: bottom overflow in Folder Organization dialog 2026-01-14 01:00:52 +07:00
zarzet 8ab52959e8 refactor: simplify parallel download result handling in tidal/qobuz 2026-01-14 00:57:04 +07:00
zarzet bad95e99c8 fix: remove unused getDownloadURLSequential from tidal.go
Replaced by parallel version for faster API responses
2026-01-14 00:39:59 +07:00
zarzet dbd7fd70be fix: remove unused function and fix bit shifting warnings
- Remove unused getQobuzDownloadURLSequential (replaced by parallel version)
- Fix bit shifting on byte values in metadata.go (cast to uint32 before shift)
2026-01-14 00:38:46 +07:00
zarzet 125d070cfe fix: remove duplicate --- separator in release notes
Extract changelog now strips trailing --- from CHANGELOG.md sections
2026-01-13 23:51:59 +07:00
zarzet 15acf181d1 fix: back gesture freeze on Android 13+ and add album folder structure setting
- Add PopScope with canPop:true to all settings pages for predictive back gesture support
- Change settings navigation to use PageRouteBuilder instead of MaterialPageRoute
- Add album folder structure setting (artist_album vs album_only)
- Fix extension search result parsing to handle both array and object formats
- Update CHANGELOG

Fixes back gesture freeze issue on OnePlus and other Android 13+ devices with gesture navigation
2026-01-13 23:48:02 +07:00
zarzet e049f9b868 fix: improve artist matching for multi-artist tracks and add cover logging 2026-01-13 20:55:46 +07:00
zarzet 6a886c5276 fix: handle Japanese artist name order in Tidal/Qobuz matching 2026-01-13 20:31:05 +07:00
zarzet 1ec190bfe7 fix: multiple bugfixes for v3.0.0-beta.2 2026-01-13 20:12:35 +07:00
zarzet 7ca032b3f5 fix: remove unnecessary PopScope to prevent back gesture freeze
Removes PopScope wrapper from settings pages that don't need it.
PopScope with canPop: true was causing race condition with Android
gesture navigation, freezing the app.
2026-01-13 18:18:41 +07:00
zarzet 13b917d1a0 fix: preserve directory structure when extracting extension packages 2026-01-13 17:50:12 +07:00
zarzet 961072e2ac security: use per-installation random salt for credential encryption 2026-01-13 17:44:14 +07:00
zarzet 8a7815268b security: improve extension sandbox security
- Add file permission requirement for extensions

- Bump version to 3.0.0-beta.1
2026-01-13 17:41:24 +07:00
zarzet c7e1ffd926 chore: bump version to 3.0.0-alpha.4 2026-01-13 06:01:12 +07:00
zarzet 729ab01a5f feat(extension): add HMAC-SHA1 utility, artist URL handler, and store refresh fix
- Add utils.hmacSHA1(key, message) for extensions
- Add artist type handling in track_provider for extension URL results
- Fix extension store not refreshing after uninstall
- Update CHANGELOG with new features and Spotify Web extension
2026-01-13 05:54:19 +07:00
zarzet 0a16be4395 feat(extension): add HMAC-SHA1 utility, artist URL handler, and store refresh fix
- Add utils.hmacSHA1(key, message) for cryptographic operations in extensions
- Add artist type handling in track_provider for extension URL results
- Fix extension store not refreshing after uninstall
- Update CHANGELOG with new features and Spotify Web extension docs
2026-01-13 05:53:30 +07:00
zarzet 47cdb5564a fix(store): refresh store after extension uninstall to update isInstalled status 2026-01-13 04:30:25 +07:00
zarzet f7d5a24d17 refactor(extension): split extension_runtime.go into multiple files + add HMAC-SHA256 2026-01-13 04:17:00 +07:00
zarzet 8daff4d0a4 feat: improve Extension Store with custom icons and various fixes
- Support custom extension icons from registry (iconUrl field)
- Support both camelCase and snake_case in registry JSON
- Fix download file extension (.spotiflac-ext)
- New extensions start disabled by default
- Preserve enabled state on extension upgrade
- Add toggle to show/hide Store tab in Settings > Options
- Reorder tabs: Home, History, Store, Settings
2026-01-13 01:01:43 +07:00
zarzet a38d66fd41 feat: add Extension Store for browsing and installing extensions 2026-01-13 00:03:39 +07:00
zarzet 0cab01780d fix: gomobile compatibility for HandleURLWithExtension return type 2026-01-12 23:43:57 +07:00
zarzet 4afc14dee8 chore: increase log buffer size from 500 to 1000 entries 2026-01-12 23:18:47 +07:00
zarzet 00753ffe86 chore: increase log buffer size from 500 to 1000 entries 2026-01-12 23:17:30 +07:00
zarzet 523b1edc44 feat(extension): add custom URL handler support for extensions
- Add URLHandlerConfig to extension manifest (Go)
- Add HandleURL method to extension providers (Go)
- Add export functions for URL handling (Go)
- Add URLHandler class to extension_provider.dart (Flutter)
- Add platform bridge methods for URL handling (Flutter)
- Update fetchFromUrl to check extension URL handlers first
- Add Android/iOS native handlers for extension URL routing
- Update CHANGELOG with new feature
2026-01-12 22:22:25 +07:00
zarzet 4966a84614 chore: bump version to 3.0.0-alpha.3 2026-01-12 22:02:29 +07:00
zarzet 9247a775fa feat(extension): add browser-like polyfills for easier library porting
- Add fetch() API with json(), text(), arrayBuffer() methods
- Add atob()/btoa() global Base64 functions
- Add TextEncoder/TextDecoder classes for UTF-8 encoding
- Add URL/URLSearchParams classes for URL parsing
- Update documentation with polyfill usage examples
- All polyfills work within sandbox security model
2026-01-12 21:18:04 +07:00
zarzet b185b51b31 merge: sync bugfixes from main (permission error, Android 13+ storage) 2026-01-12 19:59:09 +07:00
zarzet d98960d053 fix: permission error message and Android 13+ storage permission
- Fixed download showing 'Song not found' when actually permission error
- Added permission error type detection in Go backend
- Android 13+ now requests both MANAGE_EXTERNAL_STORAGE and READ_MEDIA_AUDIO
- MANAGE_EXTERNAL_STORAGE opens Settings (system-level)
- READ_MEDIA_AUDIO shows dialog (app-level, resets on clear data)
- Proper permission check before showing 'granted' status
2026-01-12 19:56:12 +07:00
zarzet d417743654 chore: bump version to 2.2.9 2026-01-12 18:47:53 +07:00
zarzet c4bea124fb perf: parallel API calls for Tidal and Qobuz download URLs
- Tidal: Request download URL from all 8 APIs simultaneously
- Qobuz: Request download URL from all 2 APIs simultaneously
- First successful response wins ('siapa cepat dia dapat')
- Significantly reduces download URL fetch time
- Amazon remains sequential due to rate limiting requirements

This improves download speed by eliminating sequential API fallback delays.
2026-01-12 18:32:59 +07:00
zarzet c37410b5de feat: add Separate Singles Folder option
- Add albumType field to Track model with isSingle getter
- Add separateSingles setting in AppSettings
- Modify _buildOutputDir() to organize into Albums/ and Singles/ folders
- Add UI toggle in download settings page
- Parse album_type/record_type from Spotify and Deezer APIs

When enabled, singles are saved to a separate 'Singles' folder
2026-01-12 18:27:38 +07:00
zarzet b90c94125c merge: sync bugfix from main (duplicate history fix) 2026-01-12 18:26:06 +07:00
zarzet efbf5d4c5b fix: prevent duplicate entries in download history
- Add duplicate detection in addToHistory() by spotifyId, deezerId, or ISRC
- Replace existing entry and move to top when re-downloading same track
- Add _deduplicateHistory() to clean up existing duplicates on app load
- Auto-save after removing duplicates from storage

Fixes duplicate history entries when downloading same track multiple times
2026-01-12 18:25:38 +07:00
zarzet 35532b0c73 feat(extension): Enhanced HTTP API for YouTube Music support
- Add http.put(), http.delete(), http.patch() shortcut methods
- Add persistent cookie jar per extension
- Add http.clearCookies() to clear session
- Fix User-Agent header respect (no longer overwritten)
- Return multi-value headers as arrays (Set-Cookie support)
- Auto-stringify objects in POST/PUT/PATCH body
- Add response.ok and response.status properties
- Update documentation with YouTube Music example
2026-01-12 06:37:18 +07:00
zarzet 4c09b988e4 Merge main into dev (sync v2.2.8 features) 2026-01-12 06:22:22 +07:00
zarzet bcd718b178 fix: reset settings when extension is disabled
- Reset metadata source to Deezer when search provider extension is disabled
- Reset default service to Tidal when download provider extension is disabled
- Check extension enabled state in Options page (Primary Provider)
- Check extension enabled state in Download Settings (Service selector)
- Show extension download providers in service selector when enabled
2026-01-12 02:26:18 +07:00
zarzet 2b9357cb6d feat: remove default Spotify credentials, require user's own API key
- Remove hardcoded Spotify client ID/secret from Go backend
- Spotify now requires user to provide their own credentials
- Deezer remains free (no credentials required)
- Update UI to show 'Free' badge for Deezer, 'API Key' for Spotify
- Show warning card when Spotify selected without credentials
- Add hasSpotifyCredentials check to platform bridge
2026-01-12 02:10:40 +07:00
zarzet 26d84041c7 fix: initialize extension system at app start for proper search hint
- Move extension system initialization to main.dart _EagerInitialization
- Show default search hint until extension system is initialized
- Watch extension state changes to update search hint dynamically
2026-01-12 01:58:44 +07:00
zarzet 93b4047143 fix: persist extension enabled state and clear search provider when disabled
- Save enabled state to settings store when extension is enabled/disabled
- Restore enabled state from settings store when extension is loaded
- Clear searchProvider setting when the extension is disabled
- Update search hint to check if extension is still enabled
2026-01-12 01:56:16 +07:00
zarzet 3dbd131e49 fix: iOS extension auth function names (use ByID suffix) 2026-01-12 01:02:16 +07:00
zarzet 57cb575483 feat: add extension system with skipBuiltInFallback support
- Add extension manager, manifest, runtime, providers, settings
- Add extension provider and UI pages (extensions, detail, priority)
- Add download service picker widget
- Add metadata provider priority page
- Add source field to Track model for extension tracking
- Add skipBuiltInFallback manifest option to skip built-in providers
- Update download queue to use source extension first
- Add extension upgrade support without data loss
2026-01-12 00:17:52 +07:00
122 changed files with 86914 additions and 2660 deletions
@@ -1,5 +1,5 @@
name: Extension API Feature Request (Alpha)
description: Request new API features or capabilities for extension development (Extension system is in alpha)
name: Extension API Feature Request
description: Request new API features or capabilities for extension development
title: "[Extension API]: "
labels: ["enhancement", "extension-api"]
body:
@@ -15,7 +15,7 @@ body:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have read the [Extension Development Guide](https://zarz.moe/docs)
- label: I have read the [Extension Development Guide](https://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
required: true
- label: I have searched existing issues and this API feature hasn't been requested yet
required: true
+2
View File
@@ -345,6 +345,8 @@ jobs:
CHANGELOG="See CHANGELOG.md for details."
else
echo "Found changelog content"
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
fi
# Save to file for multiline support
+18 -1
View File
@@ -13,7 +13,7 @@ Thumbs.db
# Reference folder (development only)
referensi/
# Documentation (hosted separately)
# Documentation (development only, published separately)
docs/
# Old spotiflac_android folder (moved to root)
@@ -53,3 +53,20 @@ ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
# Extension folder
extension/
# Agent instructions
AGENTS.md
# Temp/misc
nul
# Log files
*.log
hs_err_*.log
flutter_*.log
# Development tools
tool/
+630
View File
@@ -1,5 +1,633 @@
# Changelog
## [3.1.0] - 2026-01-19
### Added
- **Recent Access History**: Quick access to recently visited content when tapping the search bar
- Shows recently visited artists, albums, playlists, and downloaded tracks
- Merged view combining navigation history and download history
- Tap to quickly navigate back to previously accessed content
- X button to remove individual items from history
- "Clear All" button to clear entire history
- Persists across app restarts (stored in SharedPreferences)
- Max 20 items stored, sorted by most recent
- Multi-language support (Artist/Album/Song/Playlist labels localized)
- **Artist Screen Redesign**
- Full-width header image (380px) with gradient overlay
- Artist name displayed at bottom of header with text shadow
- Monthly listeners count display (formatted with compact notation)
- "Popular" section showing top 5 tracks with download status indicators
- Dynamic download button states (queued, downloading, completed)
- Header image and top tracks fetched from extension metadata
- Image alignment set to top-center to show faces properly
- **Extension Store Update Badge**: Badge indicator on Store tab icon showing number of available updates
- Users can see extension updates are available without opening Store tab
- Badge shows count of extensions with updates
- **Extension Compatibility Warning**: Warning badge for extensions requiring newer app version
- Extensions with `minAppVersion` higher than current app show warning label
- Label displays "Requires vX.X.X+" to encourage users to upgrade
- Users can still install the extension (not blocked)
- **Year in Album Folder Name** ([#50](https://github.com/zarzet/SpotiFLAC-Mobile/issues/50)): New album folder structure options with release year
- `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/
- `[Year] Album Only`: Albums/[2005] X&Y/
- Year extracted from release date metadata
- Matches desktop SpotiFLAC folder structure
- **Extension Album/Playlist/Artist Support**: Extensions can now return albums, playlists, and artists in search results
- Search results now properly separated into Albums, Playlists, Artists, and Songs sections
- Albums, playlists, and artists show chevron icon (navigate to detail) instead of download button
- Tap album/playlist to view track list and download
- Tap artist to view their albums/discography
- New `getAlbum()`, `getPlaylist()`, and `getArtist()` extension functions
- New `ExtensionAlbumScreen`, `ExtensionPlaylistScreen`, and `ExtensionArtistScreen` for fetching content from extensions
- YouTube Music extension updated with album/playlist/artist support
- **Odesli (song.link) Integration for YouTube Music Extension**
- New `enrichTrack()` function to fetch ISRC and external service links
- Uses Odesli API to convert YouTube Music tracks to Deezer/Tidal/Qobuz
- Enables built-in service fallback for high-quality audio downloads
- Extension version updated to 1.4.0 with `api.song.link` and `odesli.io` network permissions
- **Download Cancel**: Canceling a download now stops in-flight built-in provider downloads (Tidal/Qobuz/Amazon) and clears backend progress tracking.
### Changed
- **Search Bar Behavior**: Tapping search bar now immediately moves it to top position
- Logo and subtitle hide when search bar is focused
- Recent access history appears in the content area below
- More space for recent items, not blocked by keyboard
### Fixed
- Fixed search source chips still referencing removed badge props.
- Fixed extension artist album metadata to preserve provider IDs and cover URLs for correct navigation.
- Fixed extension playlist fetch to populate provider IDs and reject disabled extensions.
- Fixed extension collection screens calling setState after dispose during async loads.
- Fixed URL handler responses to include provider IDs for extension albums and artists.
- Fixed YTMusic extension not extracting album name and duration from search results.
- Album name is now extracted from flexColumns/subtitle when linked to album browseId.
- Duration is now extracted from fixedColumns/flexColumns in addition to existing sources.
- Fixed "Separate Singles" setting not working ([#54](https://github.com/zarzet/SpotiFLAC-Mobile/issues/54)) - singles were going to Albums folder.
- Root cause: `albumType` was not being extracted from Deezer API during metadata enrichment.
- Deezer track responses now correctly include `album_type` (single/ep/album/compilation).
- Track creation now preserves `albumType` and `source` fields throughout download flow.
- Fixed PageView overscroll at edges (BouncingScrollPhysics → ClampingScrollPhysics)
- Fixed settings item highlight on swipe (highlightColor: Colors.transparent)
- Fixed extension duplicate load error (skip silently instead of throwing error)
- Fixed keyboard appearing when swiping between tabs (unfocus on page change)
- Removed "Free"/"API Key" badges from search source selector
- Fixed cancel action briefly resuming downloads in the queue UI after ~1 second.
- Fixed cancelled downloads being marked as failed when the backend returns after cancellation.
- Fixed cancel triggering provider fallback (cancel now stops the download flow immediately).
- Fixed stale ISRC cache returning deleted files after cancel.
- Fixed search results mixing extension and built-in artists when using default provider.
- Fixed audio files opening with non-music apps by passing audio MIME type on open.
- Fixed album artist showing null/blank by normalizing empty metadata and using artist fallback for tags.
- Fixed `use_build_context_synchronously` lint warnings in `home_tab.dart`
- Fixed `unnecessary_underscores` lint warnings in error widget callbacks
- Fixed duplicate artist entries in recent history (recording now only happens in screen's initState)
- **Go Backend: Missing `item_type` and `album_type` fields**
- Added `ItemType` and `AlbumType` fields to `ExtTrackMetadata` struct
- Fixed `CustomSearchWithExtensionJSON` - now includes `item_type` and `album_type` in response
- Fixed `HandleURLWithExtensionJSON` - now includes `item_type` and `album_type` for tracks
- Fixed `GetAlbumWithExtensionJSON` - now includes `item_type` and `album_type` for album tracks
- Fixed `GetPlaylistWithExtensionJSON` - now includes `item_type` and `album_type` for playlist tracks
- **Album/Playlist Track Thumbnails**: Tracks inside albums/playlists now use album/playlist cover as fallback when no individual cover exists
- **YouTube Music Extension getArtist**: Fixed `getArtist()` function not being registered in extension, causing artist pages to fail with "returned null" error
- **Recent Access UI**: Fixed recent access list disappearing when keyboard is dismissed - now stays visible until user presses Back button
- **Extension Artist Top Tracks**: Fixed top tracks not appearing when opening artist from extension search results
- YT Music extension `getArtist()` now returns `top_tracks` array with up to 10 popular songs
- Go backend `GetArtistWithExtensionJSON` now forwards `top_tracks`, `header_image`, and `listeners` to Flutter
- `ExtensionArtistScreen` now parses and passes top tracks to `ArtistScreen`
- `ArtistScreen` with `extensionId` skips Spotify/Deezer fetch, uses extension data only (fixes "Rate Limited" errors)
- **Search Bar Unfocus**: Fixed search bar not unfocusing when tapping outside - now properly dismisses keyboard and unfocus when tapping anywhere outside the search field
- **Keyboard Appearing on Settings Navigation**: Fixed keyboard randomly appearing when returning from Settings sub-pages (e.g., Appearance) - now uses `FocusManager.instance.primaryFocus?.unfocus()` for more aggressive unfocus
- **Recent Access Artist Navigation**: Fixed opening artist from recent access using wrong screen - now correctly uses `ExtensionArtistScreen` for extension artists (YT Music, Spotify Web) instead of trying to fetch from Spotify API
### Extensions
- **YouTube Music Extension**: Updated to v1.5.0
- `getArtist()` now returns `top_tracks` array with popular songs
- Added `header_image` and `listeners` to artist response
- **Spotify Web Extension**: Updated to v1.6.0
### Localization
- **Multi-Language Support**: App now supports multiple languages with community contributions via Crowdin
- Available languages: English, Indonesian (Bahasa Indonesia)
- More languages coming soon with community translations
- Contribute translations at [Crowdin](https://crowdin.com/project/spotiflac-mobile)
- Added new localization strings for recent access types:
- `recentTypeArtist` - "Artist" / "Artis"
- `recentTypeAlbum` - "Album" / "Album"
- `recentTypeSong` - "Song" / "Lagu"
- `recentTypePlaylist` - "Playlist" / "Playlist"
- `recentPlaylistInfo` - "Playlist: {name}"
- `errorGeneric` - "Error: {message}"
---
## [3.0.0] - 2026-01-14
### Extension System (Major Feature)
SpotiFLAC 3.0 introduces a powerful extension system that allows third-party integrations for metadata, downloads, and more.
#### Extension Store
- Browse and install extensions directly from the app
- New "Store" tab in bottom navigation
- Browse by category: Metadata, Download, Utility, Lyrics, Integration
- Search extensions by name, description, or tags
- One-tap install, update, and uninstall
- Offline cache for browsing without internet
#### Spotify Web Extension
- Available in Extension Store - install and enable in Settings > Extensions
- Metadata provider using Spotify's internal web player API
- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists
- Useful when official Spotify API is rate-limited or unavailable
#### Extension Capabilities
- **Custom Search Providers**
- **Custom URL Handlers**
- **Custom Thumbnail Ratios**: Square (1:1), Wide (16:9), Portrait (2:3)
- **Post-Processing Hooks**: Extensions can process downloaded files
- **Quality Options**: Extensions can define custom quality settings
#### Extension APIs
- Full HTTP support: GET, POST, PUT, DELETE, PATCH
- Persistent cookie jar per extension
- Browser-like polyfills: `fetch()`, `atob()`/`btoa()`, `TextEncoder`/`TextDecoder`, `URL`/`URLSearchParams`
- Storage API for persistent data
- File API for file operations
- HMAC-SHA1 utility for cryptographic operations
#### Security
- Sandboxed JavaScript runtime (goja)
- Permission-based access control
- Network domain whitelisting
- Improved credential encryption with per-installation random salt
### Added
- **Album Folder Structure Setting**: Option to remove artist folder from album path
- `Artist / Album` (default): `Albums/Artist Name/Album Name/`
- `Album Only`: `Albums/Album Name/`
- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders
- Based on `album_type` from Spotify/Deezer metadata
- Toggle in Settings > Download > Separate Singles Folder
- **Year in Album Folder Name**: New album folder structure options with release year
- `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/
- `[Year] Album Only`: Albums/[2005] X&Y/
- Year extracted from release date metadata
- Matches desktop SpotiFLAC folder structure
- **Parallel API Calls**: Download URL fetching now uses parallel requests
- Tidal: All 8 APIs requested simultaneously, first success wins
- Qobuz: Both APIs requested simultaneously, first success wins
- Significantly reduces download URL fetch time
### UI/UX Improvements
- **Swipeable History Filters**: History tab now supports swipe gestures between All, Albums, and Singles filters
- Swipe left/right to switch between filter tabs
- Filter chips sync with swipe position
- Smooth edge-to-edge transition: swipe past Singles to go to Store, swipe past All to go to Home
- Natural gesture feel - drag connects to parent navigation
- **Improved File Open Intent**: Play button in History now correctly opens music players only
- Added proper MIME type (`audio/flac`, `audio/mpeg`, etc.) when opening downloaded files
- Prevents system from showing unrelated apps in the "Open with" dialog
### Fixed
- **Fixed Tab Edge Overscroll**: Home and Settings tabs now stop at edges instead of bouncing into empty space
- **Fixed Extension Duplicate Load Error**: Extension loading now silently skips already-loaded extensions instead of throwing error
- **Fixed Settings Item Highlight on Swipe**: Settings items no longer highlight when swiping at page edge
- **Fixed Keyboard Appearing on Tab Switch**: Keyboard now auto-dismisses when swiping between tabs
- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from Deezer/Spotify selector in Options
- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings
- Added `PopScope` with `canPop: true` to all settings pages
- Changed navigation to use `PageRouteBuilder` with proper slide transition
- **Bottom Overflow in Folder Organization Dialog**: Fixed overflow in portrait and landscape mode
- Made dialog scrollable with max height constraint
- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names
- "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches
- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks
- "RADWIMPS feat. Toko Miura" now matches when service only shows "Toko Miura"
- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile
- Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000)
- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen
- Duplicate detection prefix now stripped before saving to history
- **Extension Search Result Parsing**: Fixed "cannot unmarshal array" error
- Go backend now handles both array and object formats from extensions
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error
- Now shows proper message: "Cannot write to folder, check storage permission"
- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+
- Now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO`
### Changed
- **Extension Manifest**: New `file` permission required for file operations
```json
"permissions": {
"network": ["api.example.com"],
"storage": true,
"file": true
}
```
### Technical
- Go backend: Simplified parallel download result handling in Tidal/Qobuz
- Go backend: Removed unused functions and fixed bit shifting warnings
- Release workflow: Fixed duplicate `---` separator in release notes
---
## [3.0.0-beta.2] - 2026-01-13
### Added
- **Album Folder Structure Setting**: Option to remove artist folder from album path
- New setting in Download Settings when "Separate Singles Folder" is enabled
- `Artist / Album` (default): `Albums/Artist Name/Album Name/`
- `Album Only`: `Albums/Album Name/`
- Requested by user who prefers flat album organization
### Fixed
- **Back Gesture Freeze on OnePlus/Android 13+**: Fixed app freeze when using back gesture in settings
- Added `PopScope` with `canPop: true` to all settings pages
- Changed navigation to use `PageRouteBuilder` with proper slide transition
- Fixes predictive back gesture conflict on devices with gesture navigation
- Affected pages: Download, Appearance, Options, Extensions, About, Logs, Extension Detail
- **Extension Search Result Parsing**: Fixed "cannot unmarshal array into Go value" error
- Go backend now handles both array and object formats from extensions
- Extensions returning `[{track}, {track}]` now work correctly
- Extensions returning `{tracks: [...], total: N}` still work as before
- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile
- Added missing `spotifySize300` constant (300x300 size code)
- Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000)
- Added `_upgradeToMaxQualityCover()` helper in Flutter for M4A conversion path
- Go backend `cover.go` now directly replaces URL without HEAD verification
- **Extension Search Provider Reset**: Fixed search provider not resetting to default when disabled
- `copyWith` in `AppSettings` couldn't set `searchProvider` to `null`
- Added `clearSearchProvider` boolean parameter to properly clear the value
- Settings menu now correctly switches back to default provider
- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called
- `_performSearch` now checks if extension is still enabled before calling custom search
- Automatically falls back to Deezer/Spotify search if extension was disabled
- Clears `searchProvider` setting if extension no longer available
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
- Added `mounted` check after async operation in `_initialize()`
- Prevents crash when navigating away from Store tab during initialization
- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen after download
- Duplicate detection was adding `EXISTS:` prefix to file paths
- Prefix now stripped before saving to download history
- Legacy history items with prefix are handled gracefully
- **History Error Badge**: Fixed error badge showing on history items even when file exists
- `queue_tab.dart` now strips `EXISTS:` prefix before checking file existence
- File open and delete operations also use cleaned path
- **Extension Artist URL Handler**: Fixed artist pages showing "0 releases" from extensions
- Extension `fetchArtist` now returns correct format: `{ type: "artist", artist: { albums } }`
- Go backend `HandleURLWithExtensionJSON` now includes albums in artist response
- Added `AlbumType` field to `ExtAlbumMetadata` struct
- **Extension Artist Name in Logs**: Fixed empty artist name in extension track logs
- Now uses `firstArtist` + `otherArtists` instead of deprecated `artists.items`
- Logs correctly show "Fetched track: {title} by {artist}"
- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names with different order
- "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches
- Added `sameWordsUnordered` check to both Tidal and Qobuz artist matching
- Handles Japanese name order (family name first) vs Western name order (given name first)
- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks
- "RADWIMPS feat. Toko Miura" now matches when Qobuz/Tidal only shows "Toko Miura"
- Split artists by separators (`, `, `feat.`, `ft.`, `&`, `and`, `x`)
- Match if ANY expected artist matches ANY found artist
- **Cover Download Logging**: Improved cover download logs for debugging
- Shows original URL, upgrade steps, and final URL
- Displays estimated resolution based on file size
- Logs now appear in Settings > Logs via GoLog
---
## [3.0.0-beta.1] - 2026-01-13
### Security
- Improved extension sandbox security
- Improved credential encryption with per-installation random salt
### Changed
- **Extension Manifest**: New `file` permission required for file operations
```json
"permissions": {
"network": ["api.example.com"],
"storage": true,
"file": true
}
```
Extensions that need to download files must declare `"file": true` in manifest.
### Fixed
- Extension packages now preserve directory structure (subdirectories supported)
- Back gesture freeze in settings pages on Android gesture navigation
---
## [3.0.0-alpha.4] - 2026-01-12
### Added
- **Extension Store**: Browse and install extensions directly from the app
- New "Store" tab in bottom navigation
- Browse extensions by category (Metadata, Download, Utility, Lyrics, Integration)
- Search extensions by name, description, or tags
- One-tap install and update
- Offline cache for browsing without internet
- Extensions hosted at github.com/zarzet/SpotiFLAC-Extension
- **Custom URL Handler for Extensions**: Extensions can now register custom URL patterns
- Handle URLs from YouTube Music, SoundCloud, Bandcamp, etc.
- Manifest config: `urlHandler: { enabled: true, patterns: ["music.youtube.com"] }`
- Implement `handleUrl(url)` function in extension to parse and return track metadata
- SpotiFLAC automatically routes matching URLs to the appropriate extension
- Supports share intents and paste from clipboard
- **Artist URL Handler Support**: Extensions can now return artist data from URL handlers
- Added `type: "artist"` handling in track_provider.dart
- Navigate to artist screen with albums list from extension
- **HMAC-SHA1 Utility**: New `utils.hmacSHA1(key, message)` function for extensions
- Enables TOTP generation and other cryptographic operations
- Returns byte array for flexible use
### Fixed
- **Extension Store Refresh**: Store tab now properly refreshes after uninstalling an extension
- "Installed" badge correctly updates to "Install" button
### Documentation
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
- Added Custom URL Handler section with examples
- Added `handleUrl` function documentation
- Added URL pattern examples for YouTube, SoundCloud, Bandcamp
- Added `utils.hmacSHA1` documentation with TOTP example
### Extensions
- **Spotify Web Extension** (example): New extension for Spotify metadata via web API
- Supports personalized playlists (Daily Mix, Discover Weekly, Release Radar, etc.)
- Search, album, playlist, track, and artist fetching
- Available in Extension Store (3.0.0-alpha.4)
---
## [3.0.0-alpha.3] - 2026-01-12
### Added
- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders
- Based on `album_type` from Spotify/Deezer metadata
- Toggle in Settings > Download > Separate Singles Folder
- Singles saved to `{output}/Singles/`, albums to `{output}/Albums/`
- **Browser-like Polyfills**: New global APIs for easier library porting
- `fetch()` - Browser-compatible HTTP API with `json()`, `text()`, `arrayBuffer()` methods
- `atob()` / `btoa()` - Global Base64 encoding/decoding
- `TextEncoder` / `TextDecoder` - UTF-8 text encoding classes
- `URL` / `URLSearchParams` - URL parsing and manipulation classes
- Makes porting browser libraries (like `youtubei.js`) much easier
### Performance
- **Parallel API Calls**: Download URL fetching now uses parallel requests
- Tidal: All 8 APIs requested simultaneously, first success wins
- Qobuz: Both APIs requested simultaneously, first success wins
- Significantly reduces download URL fetch time
### Fixed
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
- Replaces existing entry and moves to top of list
- Auto-deduplicates existing history on app load
- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search
- Now checks if extension is still enabled before calling custom search
- Auto-resets search provider to default if extension was disabled
- **Permission Error Message**: Fixed download showing "Song not found" when actually a permission error
- Now shows proper message: "Cannot write to folder, check storage permission"
- Added `permission` error type detection in backend
- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+
- Android 13+ now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO`
- `MANAGE_EXTERNAL_STORAGE` opens Settings (system-level, persists across app data clear)
- `READ_MEDIA_AUDIO` shows dialog (app-level, resets on app data clear)
- Proper permission check before showing "granted" status
---
## [3.0.0-alpha.2] - 2026-01-12
### Added
- **Full HTTP Method Support**: New shortcut methods for all common HTTP verbs
- `http.put(url, body, headers)` - PUT requests
- `http.delete(url, headers)` - DELETE requests
- `http.patch(url, body, headers)` - PATCH requests
- `http.clearCookies()` - Clear all cookies for the extension
- **Persistent Cookie Jar**: Each extension now has its own cookie jar
- Cookies automatically stored from `Set-Cookie` headers
- Cookies automatically sent with subsequent requests to same domain
- Useful for APIs requiring session cookies (YouTube, etc.)
- **Multi-Value Header Support**: Response headers now return arrays for multi-value headers
- `Set-Cookie` and other headers with multiple values returned as arrays
- Single-value headers still returned as strings for convenience
- **Generic HTTP Request Method**: New `http.request()` for full HTTP control
- Supports all HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.)
- Single options object for cleaner API: `http.request(url, { method, body, headers })`
- **Response Helper Properties**: HTTP responses now include convenience properties
- `response.ok` - true if status code is 2xx
- `response.status` - alias for `statusCode`
### Fixed
- **User-Agent Header Respect**: Custom `User-Agent` headers are now respected
- Previously, extension-provided User-Agent was overwritten
- Now only sets default User-Agent if extension doesn't provide one
- **HTTP POST Body Auto-Stringify**: `http.post()` now automatically stringifies objects to JSON
- Previously, passing an object as body resulted in `[object Object]`
- Now objects and arrays are automatically JSON.stringify'd
- String bodies still work as before (no double-encoding)
### Documentation
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
- Added complete HTTP API documentation with all methods
- Added Cookie Jar documentation
- Added `http.put()`, `http.delete()`, `http.patch()`, `http.clearCookies()` docs
- Added YouTube Music / Innertube API example with custom User-Agent
- Added common domain lists for YouTube, SoundCloud, Bandcamp
- Improved HTTP API documentation with response properties
---
## [3.0.0-alpha.1] - 2026-01-11
#### Extension System
- **Custom Search Providers**: Extensions can now provide custom search functionality
- YouTube, SoundCloud, and other platforms via extensions
- Custom search placeholder text per extension
- Configurable thumbnail aspect ratios (square, wide, portrait)
- **Extension Upgrade System**: Upgrade extensions without losing data
- Preserves extension settings and cached data during upgrades
- Version comparison prevents downgrades
- Auto-detects upgrades when installing same extension
- **Custom Thumbnail Ratios**: Extensions can specify thumbnail display format
- `"square"` (1:1) - Album art style (default)
- `"wide"` (16:9) - YouTube/video style
- `"portrait"` (2:3) - Poster style
- Custom width/height override available
### Added
- **Track Source Tracking**: Tracks now remember which extension provided them
- `Track.source` field stores extension ID
- `TrackState.searchExtensionId` for current search context
- Enables extension-specific UI customization
- **Extension Upgrade API**: New methods for extension management
- `upgradeExtension(filePath)` - Upgrade existing extension
- `checkExtensionUpgrade(filePath)` - Check if file is an upgrade
- `RemoveExtensionByID` - Remove extension by ID
- **iOS Extension Support**: Added missing iOS method handlers
- `upgradeExtension` - Upgrade extension from file
- `checkExtensionUpgrade` - Check upgrade compatibility
- **Extension Documentation**: Comprehensive extension development guide
- Thumbnail ratio customization documentation
- Extension upgrade workflow documentation
- New troubleshooting entries for common issues
### Changed
- **Version Bump**: 2.2.7 → 3.0.0-alpha.1 (major version for extension system)
- **Build Number**: 49 → 50
- **Extension Manager**: Improved upgrade detection in `LoadExtensionFromFile`
- Auto-detects if installing same extension with higher version
- Calls `UpgradeExtension` automatically for seamless upgrades
### Fixed
- **Extension `registerExtension`**: Fixed global `extension` variable not being set
- Extensions can now access their own functions via `extension.functionName()`
- Required for `customSearch` and other provider functions
- **Custom Search Empty Results**: Fixed error when extension returns null
- Now returns empty array instead of error
- Prevents crash when no results found
- **Mutex Crash on Upgrade**: Fixed "Unlock of unlocked RWMutex" crash
- Removed `defer m.mu.Unlock()` when manual unlock is used
- Proper lock handling in upgrade flow
- **Duplicate Error Messages**: Fixed extension install errors showing twice
- Added `clearError()` method to extension provider
- Improved PlatformException parsing to remove "null, null" artifacts
- **Extension Images Field**: Fixed thumbnails not showing in search results
- Added `Images` field to `ExtTrackMetadata` struct
- Renamed `GetCoverURL` to `ResolvedCoverURL` (gomobile conflict)
### Technical
- **Go Backend Changes**:
- `go_backend/extension_manager.go`: Added `compareVersions()`, `UpgradeExtension()`, `CheckExtensionUpgradeJSON()`
- `go_backend/extension_providers.go`: Added `Images` field, `ResolvedCoverURL()` method
- `go_backend/extension_manifest.go`: Added `ThumbnailRatio`, `ThumbnailWidth`, `ThumbnailHeight` to `SearchBehaviorConfig`
- `go_backend/exports.go`: Added `RemoveExtensionByID`, `UpgradeExtensionFromPath`, `CheckExtensionUpgradeFromPath`
- **Flutter Changes**:
- `lib/models/track.dart`: Added `source` field
- `lib/models/track.g.dart`: Updated for `source` field
- `lib/providers/track_provider.dart`: Added `searchExtensionId`, updated `_parseSearchTrack` with source parameter
- `lib/providers/extension_provider.dart`: Added `SearchBehavior.getThumbnailSize()`, `clearError()`
- `lib/screens/home_tab.dart`: Dynamic thumbnail size based on extension config
- `lib/screens/settings/extensions_page.dart`: Improved error handling
- `lib/services/platform_bridge.dart`: Added `upgradeExtension()`, `checkExtensionUpgrade()`, `removeExtension()`
- **iOS Changes**:
- `ios/Runner/AppDelegate.swift`: Added `upgradeExtension`, `checkExtensionUpgrade` handlers
- **Android Changes**:
- `android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt`: Already had upgrade methods
---
## [2.2.8] - 2026-01-12
### Added
@@ -24,6 +652,8 @@
- **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)"
---
## [2.2.7] - 2026-01-11
### Added
+41 -16
View File
@@ -1,5 +1,6 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center">
@@ -23,34 +24,58 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
<img src="assets/images/4.jpg?v=2" width="200" />
</p>
## Metadata Source
## Search Source
SpotiFLAC supports two metadata sources for searching tracks:
SpotiFLAC supports two search sources:
| Source | Pros | Cons |
|--------|------|------|
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
| Source | Setup |
|--------|-------|
| **Deezer** (Default) | No setup required |
| **Spotify** | Install **Spotify Web** extension from the Store, or use your own [Spotify Developer](https://developer.spotify.com) Client ID & Secret in Settings |
### Using Spotify
To use Spotify as your search source without hitting rate limits:
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
2. Create an app to get your Client ID and Client Secret
3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
4. Enter your Client ID and Secret
5. Change **Search Source** to Spotify
## Extensions
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
### Installing Extensions
1. Go to **Store** tab in the app
2. Browse and install extensions with one tap
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
4. Configure extension settings if needed
5. Set provider priority in **Settings > Extensions > Provider Priority**
### Developing Extensions
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
## FAQ
**Q: Why is my download failing with "Song not found"?**
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
**Q: Why are some tracks downloading in lower quality?**
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
**Q: Can I download my Spotify playlists?**
A: Yes! Just paste the Spotify playlist URL in the search bar. The app will fetch all tracks and queue them for download.
**Q: Why do I need to grant storage permission?**
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
**Q: How do I download Daily Mix or Discover Weekly?**
A: Install the **Spotify Web** extension from the Store. This extension can access personalized playlists that aren't available through the public API.
**Q: Is this app safe?**
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20Me-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/zarzet)
## Disclaimer
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
@@ -117,6 +117,13 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"cancelDownload" -> {
val itemId = call.argument<String>("item_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.cancelDownload(itemId)
}
result.success(null)
}
"setDownloadDirectory" -> {
val path = call.argument<String>("path") ?: ""
withContext(Dispatchers.IO) {
@@ -218,6 +225,12 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"hasSpotifyCredentials" -> {
val hasCredentials = withContext(Dispatchers.IO) {
Gobackend.checkSpotifyCredentials()
}
result.success(hasCredentials)
}
"preWarmTrackCache" -> {
val tracksJson = call.argument<String>("tracks") ?: "[]"
withContext(Dispatchers.IO) {
@@ -317,6 +330,337 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
// Extension System methods
"initExtensionSystem" -> {
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
val dataDir = call.argument<String>("data_dir") ?: ""
withContext(Dispatchers.IO) {
Gobackend.initExtensionSystem(extensionsDir, dataDir)
}
result.success(null)
}
"loadExtensionsFromDir" -> {
val dirPath = call.argument<String>("dir_path") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.loadExtensionsFromDir(dirPath)
}
result.success(response)
}
"loadExtensionFromPath" -> {
val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.loadExtensionFromPath(filePath)
}
result.success(response)
}
"unloadExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.unloadExtensionByID(extensionId)
}
result.success(null)
}
"removeExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.removeExtensionByID(extensionId)
}
result.success(null)
}
"upgradeExtension" -> {
val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.upgradeExtensionFromPath(filePath)
}
result.success(response)
}
"checkExtensionUpgrade" -> {
val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkExtensionUpgradeFromPath(filePath)
}
result.success(response)
}
"getInstalledExtensions" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getInstalledExtensions()
}
result.success(response)
}
"setExtensionEnabled" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val enabled = call.argument<Boolean>("enabled") ?: false
withContext(Dispatchers.IO) {
Gobackend.setExtensionEnabledByID(extensionId, enabled)
}
result.success(null)
}
"setProviderPriority" -> {
val priorityJson = call.argument<String>("priority") ?: "[]"
withContext(Dispatchers.IO) {
Gobackend.setProviderPriorityJSON(priorityJson)
}
result.success(null)
}
"getProviderPriority" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getProviderPriorityJSON()
}
result.success(response)
}
"setMetadataProviderPriority" -> {
val priorityJson = call.argument<String>("priority") ?: "[]"
withContext(Dispatchers.IO) {
Gobackend.setMetadataProviderPriorityJSON(priorityJson)
}
result.success(null)
}
"getMetadataProviderPriority" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getMetadataProviderPriorityJSON()
}
result.success(response)
}
"getExtensionSettings" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionSettingsJSON(extensionId)
}
result.success(response)
}
"setExtensionSettings" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val settingsJson = call.argument<String>("settings") ?: "{}"
withContext(Dispatchers.IO) {
Gobackend.setExtensionSettingsJSON(extensionId, settingsJson)
}
result.success(null)
}
"searchTracksWithExtensions" -> {
val query = call.argument<String>("query") ?: ""
val limit = call.argument<Int>("limit") ?: 20
val response = withContext(Dispatchers.IO) {
Gobackend.searchTracksWithExtensionsJSON(query, limit.toLong())
}
result.success(response)
}
"downloadWithExtensions" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
Gobackend.downloadWithExtensionsJSON(requestJson)
}
result.success(response)
}
"removeExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.removeExtensionByID(extensionId)
}
result.success(null)
}
"cleanupExtensions" -> {
withContext(Dispatchers.IO) {
Gobackend.cleanupExtensions()
}
result.success(null)
}
// Extension Auth API methods
"getExtensionPendingAuth" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionPendingAuthJSON(extensionId)
}
if (response.isNullOrEmpty()) {
result.success(null)
} else {
result.success(response)
}
}
"setExtensionAuthCode" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val authCode = call.argument<String>("auth_code") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setExtensionAuthCodeByID(extensionId, authCode)
}
result.success(null)
}
"setExtensionTokens" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val accessToken = call.argument<String>("access_token") ?: ""
val refreshToken = call.argument<String>("refresh_token") ?: ""
val expiresIn = call.argument<Int>("expires_in") ?: 0
withContext(Dispatchers.IO) {
Gobackend.setExtensionTokensByID(extensionId, accessToken, refreshToken, expiresIn.toLong())
}
result.success(null)
}
"clearExtensionPendingAuth" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.clearExtensionPendingAuthByID(extensionId)
}
result.success(null)
}
"isExtensionAuthenticated" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val isAuth = withContext(Dispatchers.IO) {
Gobackend.isExtensionAuthenticatedByID(extensionId)
}
result.success(isAuth)
}
"getAllPendingAuthRequests" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getAllPendingAuthRequestsJSON()
}
result.success(response)
}
// Extension FFmpeg API
"getPendingFFmpegCommand" -> {
val commandId = call.argument<String>("command_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getPendingFFmpegCommandJSON(commandId)
}
if (response.isNullOrEmpty()) {
result.success(null)
} else {
result.success(response)
}
}
"setFFmpegCommandResult" -> {
val commandId = call.argument<String>("command_id") ?: ""
val success = call.argument<Boolean>("success") ?: false
val output = call.argument<String>("output") ?: ""
val error = call.argument<String>("error") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setFFmpegCommandResultByID(commandId, success, output, error)
}
result.success(null)
}
"getAllPendingFFmpegCommands" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getAllPendingFFmpegCommandsJSON()
}
result.success(response)
}
// Extension Custom Search API
"customSearchWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val query = call.argument<String>("query") ?: ""
val optionsJson = call.argument<String>("options") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.customSearchWithExtensionJSON(extensionId, query, optionsJson)
}
result.success(response)
}
"getSearchProviders" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getSearchProvidersJSON()
}
result.success(response)
}
// Extension URL Handler API
"handleURLWithExtension" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.handleURLWithExtensionJSON(url)
}
result.success(response)
}
"findURLHandler" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.findURLHandlerJSON(url)
}
result.success(response)
}
"getURLHandlers" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getURLHandlersJSON()
}
result.success(response)
}
"getAlbumWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val albumId = call.argument<String>("album_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getAlbumWithExtensionJSON(extensionId, albumId)
}
result.success(response)
}
"getPlaylistWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val playlistId = call.argument<String>("playlist_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId)
}
result.success(response)
}
"getArtistWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val artistId = call.argument<String>("artist_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getArtistWithExtensionJSON(extensionId, artistId)
}
result.success(response)
}
// Extension Post-Processing API
"runPostProcessing" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.runPostProcessingJSON(filePath, metadataJson)
}
result.success(response)
}
"getPostProcessingProviders" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getPostProcessingProvidersJSON()
}
result.success(response)
}
// Extension Store
"initExtensionStore" -> {
val cacheDir = call.argument<String>("cache_dir") ?: ""
withContext(Dispatchers.IO) {
Gobackend.initExtensionStoreJSON(cacheDir)
}
result.success(null)
}
"getStoreExtensions" -> {
val forceRefresh = call.argument<Boolean>("force_refresh") ?: false
val response = withContext(Dispatchers.IO) {
Gobackend.getStoreExtensionsJSON(forceRefresh)
}
result.success(response)
}
"searchStoreExtensions" -> {
val query = call.argument<String>("query") ?: ""
val category = call.argument<String>("category") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchStoreExtensionsJSON(query, category)
}
result.success(response)
}
"getStoreCategories" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getStoreCategoriesJSON()
}
result.success(response)
}
"downloadStoreExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val destDir = call.argument<String>("dest_dir") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.downloadStoreExtensionJSON(extensionId, destDir)
}
result.success(response)
}
"clearStoreCache" -> {
withContext(Dispatchers.IO) {
Gobackend.clearStoreCacheJSON()
}
result.success(null)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 84 KiB

+3
View File
@@ -0,0 +1,3 @@
files:
- source: /lib/l10n/arb/app_en.arb
translation: /lib/l10n/arb/app_%locale_with_underscore%.arb
+21 -2
View File
@@ -1,9 +1,11 @@
package gobackend
import (
"context"
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -173,7 +175,7 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string {
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
// This uses submit → poll → download mechanism
// Internal function - not exported to gomobile
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir string) (string, string, string, error) {
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) (string, string, string, error) {
var lastError error
for _, region := range a.regions {
@@ -346,13 +348,21 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
// DownloadFile downloads a file from URL with User-Agent and progress tracking
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)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
@@ -361,6 +371,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
resp, err := a.client.Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err
}
defer resp.Body.Close()
@@ -400,6 +413,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
@@ -527,6 +543,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return AmazonDownloadResult{}, ErrDownloadCancelled
}
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
+79
View File
@@ -0,0 +1,79 @@
package gobackend
import (
"context"
"errors"
"sync"
)
// ErrDownloadCancelled is returned when a download is cancelled by the user.
var ErrDownloadCancelled = errors.New("download cancelled")
type cancelEntry struct {
cancel context.CancelFunc
canceled bool
}
var (
cancelMu sync.Mutex
cancelMap = make(map[string]*cancelEntry)
)
func initDownloadCancel(itemID string) context.Context {
if itemID == "" {
return context.Background()
}
cancelMu.Lock()
defer cancelMu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{
cancel: cancel,
canceled: false,
}
return ctx
}
func cancelDownload(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
if ok {
entry.canceled = true
if entry.cancel != nil {
entry.cancel()
}
} else {
cancelMap[itemID] = &cancelEntry{canceled: true}
}
cancelMu.Unlock()
// Hide progress for cancelled items.
RemoveItemProgress(itemID)
}
func isDownloadCancelled(itemID string) bool {
if itemID == "" {
return false
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
canceled := ok && entry.canceled
cancelMu.Unlock()
return canceled
}
func clearDownloadCancel(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
delete(cancelMap, itemID)
cancelMu.Unlock()
}
+48 -26
View File
@@ -9,10 +9,20 @@ import (
// Spotify image size codes (same as PC version)
const (
spotifySize640 = "ab67616d0000b273" // 640x640
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
)
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
// Same logic as PC version for consistency
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
}
return imageURL
}
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
// This avoids file permission issues on Android
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
@@ -20,17 +30,27 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return nil, fmt.Errorf("no cover URL provided")
}
fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL)
GoLog("[Cover] Original URL: %s", coverURL)
// Upgrade to max quality if requested
downloadURL := coverURL
// First upgrade small (300) to medium (640) - always do this
downloadURL := convertSmallToMedium(coverURL)
if downloadURL != coverURL {
GoLog("[Cover] Upgraded 300x300 → 640x640")
}
// Then upgrade to max quality if requested
if maxQuality {
downloadURL = upgradeToMaxQuality(coverURL)
if downloadURL != coverURL {
fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL)
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
GoLog("[Cover] Upgraded to max resolution (~2000x2000)")
} else {
GoLog("[Cover] Max resolution not available, using 640x640")
}
}
GoLog("[Cover] Final URL: %s", downloadURL)
client := NewHTTPClientWithTimeout(DefaultTimeout)
// Create request with User-Agent (required by Spotify CDN)
@@ -54,12 +74,25 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return nil, fmt.Errorf("failed to read cover data: %w", err)
}
fmt.Printf("[Cover] Downloaded %d bytes\n", len(data))
// Calculate approximate resolution from file size
// JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB
sizeKB := len(data) / 1024
var resolution string
if sizeKB > 200 {
resolution = "~2000x2000 (hi-res)"
} else if sizeKB > 50 {
resolution = "~640x640"
} else {
resolution = "~300x300"
}
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
return data, nil
}
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
// Uses same logic as PC version - replaces 640x640 size code with max resolution
// Same logic as PC version - directly replaces 640x640 size code with max resolution
// No HEAD verification needed - Spotify CDN always serves max resolution if available
func upgradeToMaxQuality(coverURL string) string {
// Spotify image URLs can be upgraded by changing the size parameter
// Format: https://i.scdn.co/image/ab67616d0000b273...
@@ -67,21 +100,7 @@ func upgradeToMaxQuality(coverURL string) string {
// ab67616d000082c1 = Max resolution (~2000x2000)
if strings.Contains(coverURL, spotifySize640) {
// Try max resolution first
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
// Verify max resolution URL is available
client := NewHTTPClientWithTimeout(DefaultTimeout)
req, err := http.NewRequest("HEAD", maxURL, nil)
if err == nil {
resp, err := DoRequestWithUserAgent(client, req)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return maxURL
}
}
}
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
return coverURL
@@ -93,9 +112,12 @@ func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
return ""
}
// Always upgrade small to medium first
result := convertSmallToMedium(imageURL)
if maxQuality {
return upgradeToMaxQuality(imageURL)
result = upgradeToMaxQuality(result)
}
return imageURL
return result
}
+9 -3
View File
@@ -89,11 +89,9 @@ type deezerAlbumSimple struct {
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` // Sometimes at album level
RecordType string `json:"record_type"` // album, single, ep, compile
}
// ... (skip other structs as they are fine/unchanged) ...
// ... (in convertTrack) ...
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
artistName := track.Artist.Name
if len(track.Contributors) > 0 {
@@ -146,6 +144,7 @@ type deezerAlbumFull struct {
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"` // album, single, ep, compile
Artist deezerArtist `json:"artist"`
Contributors []deezerArtist `json:"contributors"`
Tracks struct {
@@ -326,6 +325,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
// Normalize record_type (Deezer uses "compile" instead of "compilation")
albumType := album.RecordType
if albumType == "compile" {
albumType = "compilation"
}
for _, track := range album.Tracks.Data {
trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr]
@@ -345,6 +350,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
AlbumType: albumType,
})
}
+24 -1
View File
@@ -103,6 +103,18 @@ 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
}
idx.mu.Lock()
defer idx.mu.Unlock()
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) {
@@ -138,7 +150,18 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
// Use index for fast lookup
idx := GetISRCIndex(outputDir)
return idx.lookup(isrc)
filePath, exists := idx.lookup(isrc)
if !exists {
return "", false
}
if !CheckFileExists(filePath) {
// Stale index entry; remove it and return not found.
idx.remove(isrc)
return "", false
}
return filePath, true
}
// CheckISRCExists is the exported version for gomobile (returns string, error)
+1166 -20
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+315
View File
@@ -0,0 +1,315 @@
// Package gobackend provides extension manifest parsing and validation
package gobackend
import (
"encoding/json"
"fmt"
"strings"
)
// ExtensionType represents the type of extension
type ExtensionType string
const (
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
)
// SettingType represents the type of a setting field
type SettingType string
const (
SettingTypeString SettingType = "string"
SettingTypeNumber SettingType = "number"
SettingTypeBool SettingType = "boolean"
SettingTypeSelect SettingType = "select"
)
// 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
}
// ExtensionSetting defines a configurable setting for an extension
type ExtensionSetting struct {
Key string `json:"key"`
Type SettingType `json:"type"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type
}
// 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
}
// QualitySpecificSetting represents a setting that's specific to a quality option
type QualitySpecificSetting struct {
Key string `json:"key"`
Type SettingType `json:"type"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type
}
// 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
}
// 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")
}
// 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
}
// 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"])
}
// PostProcessingConfig defines post-processing capabilities
type PostProcessingConfig struct {
Enabled bool `json:"enabled"` // Whether extension provides post-processing
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks
}
// ExtensionManifest represents the manifest.json of an extension
type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
}
// ManifestValidationError represents a validation error in the manifest
type ManifestValidationError struct {
Field string
Message string
}
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 {
return nil, fmt.Errorf("failed to parse manifest JSON: %w", err)
}
if err := manifest.Validate(); err != nil {
return nil, err
}
return &manifest, nil
}
// Validate checks if the manifest has all required fields and valid values
func (m *ExtensionManifest) Validate() error {
// Check required fields
if strings.TrimSpace(m.Name) == "" {
return &ManifestValidationError{Field: "name", Message: "name is required"}
}
if strings.TrimSpace(m.Version) == "" {
return &ManifestValidationError{Field: "version", Message: "version is required"}
}
if strings.TrimSpace(m.Author) == "" {
return &ManifestValidationError{Field: "author", Message: "author is required"}
}
if strings.TrimSpace(m.Description) == "" {
return &ManifestValidationError{Field: "description", Message: "description is required"}
}
if len(m.Types) == 0 {
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
}
// Validate extension types
for _, t := range m.Types {
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
return &ManifestValidationError{
Field: "type",
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
}
}
}
// Validate settings if present
for i, setting := range m.Settings {
if strings.TrimSpace(setting.Key) == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].key", i),
Message: "setting key is required",
}
}
if setting.Type == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].type", i),
Message: "setting type is required",
}
}
// Validate setting type
validTypes := map[SettingType]bool{
SettingTypeString: true,
SettingTypeNumber: true,
SettingTypeBool: true,
SettingTypeSelect: true,
}
if !validTypes[setting.Type] {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].type", i),
Message: fmt.Sprintf("invalid setting type: %s", setting.Type),
}
}
// Select type requires options
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].options", i),
Message: "select type requires options",
}
}
}
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 {
return true
}
}
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 {
allowed = strings.ToLower(strings.TrimSpace(allowed))
if allowed == domain {
return true
}
// Support wildcard subdomains (e.g., *.example.com)
if strings.HasPrefix(allowed, "*.") {
suffix := allowed[1:] // Remove the *
if strings.HasSuffix(domain, suffix) {
return true
}
}
}
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
}
// Parse URL to get host
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
}
}
return false
}
// GetPostProcessingHooks returns all post-processing hooks
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
if m.PostProcessing == nil {
return nil
}
return m.PostProcessing.Hooks
}
// ToJSON serializes the manifest to JSON
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
return json.Marshal(m)
}
File diff suppressed because it is too large Load Diff
+340
View File
@@ -0,0 +1,340 @@
// Package gobackend provides extension runtime with sandboxed execution
package gobackend
import (
"net/http"
"net/url"
"sync"
"time"
"github.com/dop251/goja"
)
// Default timeout for JS execution (30 seconds)
const DefaultJSTimeout = 30 * time.Second
// Global auth state for extensions (stores pending auth codes)
var (
extensionAuthState = make(map[string]*ExtensionAuthState)
extensionAuthStateMu sync.RWMutex
)
// ExtensionAuthState holds auth state for an extension
type ExtensionAuthState struct {
PendingAuthURL string
AuthCode string
AccessToken string
RefreshToken string
ExpiresAt time.Time
IsAuthenticated bool
// PKCE support
PKCEVerifier string
PKCEChallenge string
}
// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL
type PendingAuthRequest struct {
ExtensionID string
AuthURL string
CallbackURL string
}
// Global pending auth requests (Flutter polls this)
var (
pendingAuthRequests = make(map[string]*PendingAuthRequest)
pendingAuthRequestsMu sync.RWMutex
)
// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter)
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
pendingAuthRequestsMu.RLock()
defer pendingAuthRequestsMu.RUnlock()
return pendingAuthRequests[extensionID]
}
// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL)
func ClearPendingAuthRequest(extensionID string) {
pendingAuthRequestsMu.Lock()
defer pendingAuthRequestsMu.Unlock()
delete(pendingAuthRequests, extensionID)
}
// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback)
func SetExtensionAuthCode(extensionID string, authCode string) {
extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock()
state, exists := extensionAuthState[extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[extensionID] = state
}
state.AuthCode = authCode
}
// SetExtensionTokens sets access/refresh tokens for an extension
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock()
state, exists := extensionAuthState[extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[extensionID] = state
}
state.AccessToken = accessToken
state.RefreshToken = refreshToken
state.ExpiresAt = expiresAt
state.IsAuthenticated = accessToken != ""
}
// ExtensionRuntime provides sandboxed APIs for extensions
type ExtensionRuntime struct {
extensionID string
manifest *ExtensionManifest
settings map[string]interface{}
httpClient *http.Client
cookieJar http.CookieJar
dataDir string
vm *goja.Runtime
}
// NewExtensionRuntime creates a new runtime for an extension
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
// Create a cookie jar for this extension
jar, _ := newSimpleCookieJar()
runtime := &ExtensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
cookieJar: jar,
dataDir: ext.DataDir,
vm: ext.VM,
}
// Create HTTP client with redirect validation to prevent SSRF via open redirect
client := &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Validate redirect target domain against allowed domains
domain := req.URL.Hostname()
if !ext.Manifest.IsDomainAllowed(domain) {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain}
}
// Also block redirects to private/local networks (SSRF protection)
if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
}
// Default redirect limit (10)
if len(via) >= 10 {
return http.ErrUseLastResponse
}
return nil
},
}
runtime.httpClient = client
return runtime
}
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
type RedirectBlockedError struct {
Domain string
IsPrivate bool
}
func (e *RedirectBlockedError) Error() string {
if e.IsPrivate {
return "redirect blocked: private/local network access denied"
}
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
}
// 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.", // Link-local
"::1", // IPv6 localhost
"fc00:", // IPv6 private
"fe80:", // IPv6 link-local
}
hostLower := host
for _, pattern := range privatePatterns {
if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern {
return true
}
}
// Also block .local domains
if len(host) > 6 && host[len(host)-6:] == ".local" {
return true
}
return false
}
// simpleCookieJar is a simple in-memory cookie jar
type simpleCookieJar struct {
cookies map[string][]*http.Cookie
mu sync.RWMutex
}
func newSimpleCookieJar() (*simpleCookieJar, error) {
return &simpleCookieJar{
cookies: make(map[string][]*http.Cookie),
}, nil
}
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
j.mu.Lock()
defer j.mu.Unlock()
key := u.Host
j.cookies[key] = append(j.cookies[key], cookies...)
}
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
j.mu.RLock()
defer j.mu.RUnlock()
return j.cookies[u.Host]
}
// SetSettings updates the runtime settings
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("clearCookies", r.httpClearCookies)
vm.Set("http", httpObj)
// Storage API
storageObj := vm.NewObject()
storageObj.Set("get", r.storageGet)
storageObj.Set("set", r.storageSet)
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)
credentialsObj.Set("remove", r.credentialsRemove)
credentialsObj.Set("has", r.credentialsHas)
vm.Set("credentials", credentialsObj)
// Auth API (for OAuth and other auth flows)
authObj := vm.NewObject()
authObj.Set("openAuthUrl", r.authOpenUrl)
authObj.Set("getAuthCode", r.authGetCode)
authObj.Set("setAuthCode", r.authSetCode)
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)
fileObj.Set("delete", r.fileDelete)
fileObj.Set("read", r.fileRead)
fileObj.Set("write", r.fileWrite)
fileObj.Set("copy", r.fileCopy)
fileObj.Set("move", r.fileMove)
fileObj.Set("getSize", r.fileGetSize)
vm.Set("file", fileObj)
// FFmpeg API (for post-processing)
ffmpegObj := vm.NewObject()
ffmpegObj.Set("execute", r.ffmpegExecute)
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
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)
matchingObj.Set("normalizeString", r.matchingNormalizeString)
vm.Set("matching", matchingObj)
// Utilities
utilsObj := vm.NewObject()
utilsObj.Set("base64Encode", r.base64Encode)
utilsObj.Set("base64Decode", r.base64Decode)
utilsObj.Set("md5", r.md5Hash)
utilsObj.Set("sha256", r.sha256Hash)
utilsObj.Set("hmacSHA256", r.hmacSHA256)
utilsObj.Set("hmacSHA256Base64", r.hmacSHA256Base64)
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)
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)
logObj.Set("warn", r.logWarn)
logObj.Set("error", r.logError)
vm.Set("log", logObj)
// Go backend functions
gobackendObj := vm.NewObject()
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)
// Global atob/btoa - Base64 encoding (browser-compatible)
vm.Set("atob", r.atobPolyfill)
vm.Set("btoa", r.btoaPolyfill)
// TextEncoder/TextDecoder constructors
r.registerTextEncoderDecoder(vm)
// URL class for URL parsing
r.registerURLClass(vm)
// JSON global (browser-compatible)
r.registerJSONGlobal(vm)
}
+547
View File
@@ -0,0 +1,547 @@
// Package gobackend provides Auth API and PKCE support for extension runtime
package gobackend
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/dop251/goja"
)
// ==================== Auth API (OAuth Support) ====================
// authOpenUrl requests Flutter to open an OAuth URL
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "auth URL is required",
})
}
authURL := call.Arguments[0].String()
callbackURL := ""
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
callbackURL = call.Arguments[1].String()
}
// Store pending auth request for Flutter to pick up
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: authURL,
CallbackURL: callbackURL,
}
pendingAuthRequestsMu.Unlock()
// Update auth state
extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[r.extensionID] = state
}
state.PendingAuthURL = authURL
state.AuthCode = "" // Clear any previous auth code
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
return r.vm.ToValue(map[string]interface{}{
"success": true,
"message": "Auth URL will be opened by the app",
})
}
// authGetCode gets the auth code (set by Flutter after OAuth callback)
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
state, exists := extensionAuthState[r.extensionID]
if !exists || state.AuthCode == "" {
return goja.Undefined()
}
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()
defer extensionAuthStateMu.Unlock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[r.extensionID] = state
}
switch v := arg.(type) {
case string:
state.AuthCode = v
case map[string]interface{}:
if code, ok := v["code"].(string); ok {
state.AuthCode = code
}
if accessToken, ok := v["access_token"].(string); ok {
state.AccessToken = accessToken
state.IsAuthenticated = true
}
if refreshToken, ok := v["refresh_token"].(string); ok {
state.RefreshToken = refreshToken
}
if expiresIn, ok := v["expires_in"].(float64); ok {
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
}
}
return r.vm.ToValue(true)
}
// authClear clears all auth state for the extension
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.Lock()
delete(extensionAuthState, r.extensionID)
extensionAuthStateMu.Unlock()
pendingAuthRequestsMu.Lock()
delete(pendingAuthRequests, r.extensionID)
pendingAuthRequestsMu.Unlock()
GoLog("[Extension:%s] Auth state cleared\n", r.extensionID)
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()
state, exists := extensionAuthState[r.extensionID]
if !exists {
return r.vm.ToValue(false)
}
// Check if token is expired
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
return r.vm.ToValue(false)
}
return r.vm.ToValue(state.IsAuthenticated)
}
// authGetTokens returns current tokens (for extension to use in API calls)
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
return r.vm.ToValue(map[string]interface{}{})
}
result := map[string]interface{}{
"access_token": state.AccessToken,
"refresh_token": state.RefreshToken,
"is_authenticated": state.IsAuthenticated,
}
if !state.ExpiresAt.IsZero() {
result["expires_at"] = state.ExpiresAt.Unix()
result["is_expired"] = time.Now().After(state.ExpiresAt)
}
return r.vm.ToValue(result)
}
// ==================== PKCE Support ====================
// generatePKCEVerifier generates a cryptographically random code verifier
// Length should be between 43-128 characters (RFC 7636)
func generatePKCEVerifier(length int) (string, error) {
if length < 43 {
length = 43
}
if length > 128 {
length = 128
}
// Generate random bytes
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
// Use base64url encoding without padding (RFC 7636 compliant)
verifier := base64.RawURLEncoding.EncodeToString(bytes)
// Trim to exact length
if len(verifier) > length {
verifier = verifier[:length]
}
return verifier, nil
}
// generatePKCEChallenge generates a code challenge from verifier using S256 method
func generatePKCEChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
// Base64url encode without padding (RFC 7636)
return base64.RawURLEncoding.EncodeToString(hash[:])
}
// authGeneratePKCE generates a PKCE code verifier and challenge pair
// Returns: { verifier: string, challenge: string, method: "S256" }
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
// Default length is 64 characters
length := 64
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
length = int(l)
}
}
verifier, err := generatePKCEVerifier(length)
if err != nil {
GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
challenge := generatePKCEChallenge(verifier)
// Store in auth state for later use
extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[r.extensionID] = state
}
state.PKCEVerifier = verifier
state.PKCEChallenge = challenge
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier))
return r.vm.ToValue(map[string]interface{}{
"verifier": verifier,
"challenge": challenge,
"method": "S256",
})
}
// authGetPKCE returns the current PKCE verifier and challenge (if generated)
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
state, exists := extensionAuthState[r.extensionID]
if !exists || state.PKCEVerifier == "" {
return r.vm.ToValue(map[string]interface{}{})
}
return r.vm.ToValue(map[string]interface{}{
"verifier": state.PKCEVerifier,
"challenge": state.PKCEChallenge,
"method": "S256",
})
}
// 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{}{
"success": false,
"error": "config object is required",
})
}
configObj := call.Arguments[0].Export()
config, ok := configObj.(map[string]interface{})
if !ok {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "config must be an object",
})
}
// Required fields
authURL, _ := config["authUrl"].(string)
clientID, _ := config["clientId"].(string)
redirectURI, _ := config["redirectUri"].(string)
if authURL == "" || clientID == "" || redirectURI == "" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "authUrl, clientId, and redirectUri are required",
})
}
// 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{}{
"success": false,
"error": fmt.Sprintf("failed to generate PKCE: %v", err),
})
}
challenge := generatePKCEChallenge(verifier)
// Store PKCE in auth state
extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[r.extensionID] = state
}
state.PKCEVerifier = verifier
state.PKCEChallenge = challenge
state.AuthCode = "" // Clear any previous auth code
extensionAuthStateMu.Unlock()
// Build OAuth URL with PKCE parameters
parsedURL, err := url.Parse(authURL)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("invalid authUrl: %v", err),
})
}
query := parsedURL.Query()
query.Set("client_id", clientID)
query.Set("redirect_uri", redirectURI)
query.Set("response_type", "code")
query.Set("code_challenge", challenge)
query.Set("code_challenge_method", "S256")
if scope != "" {
query.Set("scope", scope)
}
// Add extra params
for k, v := range extraParams {
query.Set(k, fmt.Sprintf("%v", v))
}
parsedURL.RawQuery = query.Encode()
fullAuthURL := parsedURL.String()
// Store pending auth request for Flutter
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: fullAuthURL,
CallbackURL: redirectURI,
}
pendingAuthRequestsMu.Unlock()
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
return r.vm.ToValue(map[string]interface{}{
"success": true,
"authUrl": fullAuthURL,
"pkce": map[string]interface{}{
"verifier": verifier,
"challenge": challenge,
"method": "S256",
},
})
}
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
// Uses the stored PKCE verifier automatically
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "config object is required",
})
}
configObj := call.Arguments[0].Export()
config, ok := configObj.(map[string]interface{})
if !ok {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "config must be an object",
})
}
// Required fields
tokenURL, _ := config["tokenUrl"].(string)
clientID, _ := config["clientId"].(string)
redirectURI, _ := config["redirectUri"].(string)
code, _ := config["code"].(string)
if tokenURL == "" || clientID == "" || code == "" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "tokenUrl, clientId, and code are required",
})
}
// Get stored PKCE verifier
extensionAuthStateMu.RLock()
state, exists := extensionAuthState[r.extensionID]
var verifier string
if exists {
verifier = state.PKCEVerifier
}
extensionAuthStateMu.RUnlock()
if verifier == "" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first",
})
}
// Validate domain
if err := r.validateDomain(tokenURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
// Build token request body
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("client_id", clientID)
formData.Set("code", code)
formData.Set("code_verifier", verifier)
if redirectURI != "" {
formData.Set("redirect_uri", redirectURI)
}
// Add extra params
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
for k, v := range extraParams {
formData.Set(k, fmt.Sprintf("%v", v))
}
}
// Make token request
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
// Parse response
var tokenResp map[string]interface{}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to parse token response: %v", err),
"body": string(body),
})
}
// Check for error in response
if errMsg, ok := tokenResp["error"].(string); ok {
errDesc, _ := tokenResp["error_description"].(string)
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": errMsg,
"error_description": errDesc,
})
}
// Extract tokens
accessToken, _ := tokenResp["access_token"].(string)
refreshToken, _ := tokenResp["refresh_token"].(string)
expiresIn, _ := tokenResp["expires_in"].(float64)
if accessToken == "" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "no access_token in response",
"body": string(body),
})
}
// Store tokens in auth state
extensionAuthStateMu.Lock()
state, exists = extensionAuthState[r.extensionID]
if !exists {
state = &ExtensionAuthState{}
extensionAuthState[r.extensionID] = state
}
state.AccessToken = accessToken
state.RefreshToken = refreshToken
state.IsAuthenticated = true
if expiresIn > 0 {
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
}
// Clear PKCE after successful exchange
state.PKCEVerifier = ""
state.PKCEChallenge = ""
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
// Return full token response
result := map[string]interface{}{
"success": true,
"access_token": accessToken,
"refresh_token": refreshToken,
"token_type": tokenResp["token_type"],
}
if expiresIn > 0 {
result["expires_in"] = expiresIn
}
// Include any additional fields from response
if scope, ok := tokenResp["scope"].(string); ok {
result["scope"] = scope
}
return r.vm.ToValue(result)
}
+204
View File
@@ -0,0 +1,204 @@
// Package gobackend provides FFmpeg API for extension runtime
package gobackend
import (
"fmt"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
// ==================== FFmpeg API (Post-Processing) ====================
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
type FFmpegCommand struct {
ExtensionID string
Command string
InputPath string
OutputPath string
Completed bool
Success bool
Error string
Output string
}
// Global FFmpeg command queue
var (
ffmpegCommands = make(map[string]*FFmpegCommand)
ffmpegCommandsMu sync.RWMutex
ffmpegCommandID int64
)
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
ffmpegCommandsMu.RLock()
defer ffmpegCommandsMu.RUnlock()
return ffmpegCommands[commandID]
}
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock()
if cmd, exists := ffmpegCommands[commandID]; exists {
cmd.Completed = true
cmd.Success = success
cmd.Output = output
cmd.Error = errorMsg
}
}
// ClearFFmpegCommand removes a completed FFmpeg command
func ClearFFmpegCommand(commandID string) {
ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock()
delete(ffmpegCommands, commandID)
}
// ffmpegExecute queues an FFmpeg command for execution by Flutter
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "command is required",
})
}
command := call.Arguments[0].String()
// Generate unique command ID
ffmpegCommandsMu.Lock()
ffmpegCommandID++
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
ffmpegCommands[cmdID] = &FFmpegCommand{
ExtensionID: r.extensionID,
Command: command,
Completed: false,
}
ffmpegCommandsMu.Unlock()
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
// Wait for completion (with timeout)
timeout := 5 * time.Minute
start := time.Now()
for {
ffmpegCommandsMu.RLock()
cmd := ffmpegCommands[cmdID]
completed := cmd != nil && cmd.Completed
ffmpegCommandsMu.RUnlock()
if completed {
ffmpegCommandsMu.RLock()
result := map[string]interface{}{
"success": cmd.Success,
"output": cmd.Output,
}
if cmd.Error != "" {
result["error"] = cmd.Error
}
ffmpegCommandsMu.RUnlock()
// Cleanup
ClearFFmpegCommand(cmdID)
return r.vm.ToValue(result)
}
if time.Since(start) > timeout {
ClearFFmpegCommand(cmdID)
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "FFmpeg command timed out",
})
}
time.Sleep(100 * time.Millisecond)
}
}
// ffmpegGetInfo gets audio file information using FFprobe
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "file path is required",
})
}
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{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"bit_depth": quality.BitDepth,
"sample_rate": quality.SampleRate,
"total_samples": quality.TotalSamples,
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
})
}
// ffmpegConvert is a helper for common conversion operations
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "input and output paths are required",
})
}
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 {
options = opts
}
}
// 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)},
}
return r.ffmpegExecute(execCall)
}
+523
View File
@@ -0,0 +1,523 @@
// Package gobackend provides File API for extension runtime
package gobackend
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/dop251/goja"
)
// ==================== File API (Sandboxed) ====================
// List of allowed directories for file operations (set by Go backend for download operations)
var (
allowedDownloadDirs []string
allowedDownloadDirsMu sync.RWMutex
)
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
// This should be called by the Go backend when setting up download paths
func SetAllowedDownloadDirs(dirs []string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
allowedDownloadDirs = dirs
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
}
// AddAllowedDownloadDir adds a directory to the allowed list
func AddAllowedDownloadDir(dir string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
absDir, err := filepath.Abs(dir)
if err == nil {
allowedDownloadDirs = append(allowedDownloadDirs, absDir)
}
}
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
func isPathInAllowedDirs(absPath string) bool {
allowedDownloadDirsMu.RLock()
defer allowedDownloadDirsMu.RUnlock()
for _, allowedDir := range allowedDownloadDirs {
if strings.HasPrefix(absPath, allowedDir) {
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 (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")
}
// Clean and resolve the path
cleanPath := filepath.Clean(path)
// SECURITY: Block absolute paths by default
// Only allow if path is in explicitly allowed download directories
if filepath.IsAbs(cleanPath) {
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Check if path is in allowed download directories
if isPathInAllowedDirs(absPath) {
return absPath, nil
}
// Block all other absolute paths
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
}
// For relative paths, join with data directory (extension's sandbox)
fullPath := filepath.Join(r.dataDir, cleanPath)
// Resolve to absolute path
absPath, err := filepath.Abs(fullPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Ensure path is within data directory (prevent path traversal)
absDataDir, _ := filepath.Abs(r.dataDir)
if !strings.HasPrefix(absPath, absDataDir) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
}
return absPath, nil
}
// fileDownload downloads a file from URL to the specified path
// Supports progress callback via options.onProgress
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "URL and output path are required",
})
}
urlStr := call.Arguments[0].String()
outputPath := call.Arguments[1].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
// Validate output path (allows absolute paths for download queue)
fullPath, err := r.validatePath(outputPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
// Get options if provided
var onProgress goja.Callable
var headers map[string]string
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
optionsObj := call.Arguments[2].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
// Extract headers
if h, ok := opts["headers"].(map[string]interface{}); ok {
headers = make(map[string]string)
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
// Extract onProgress callback
if progressVal, ok := opts["onProgress"]; ok {
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
onProgress = callable
}
}
}
}
// Create directory if needed
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
// Create HTTP request
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
// Set headers
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
}
// Download file
resp, err := r.httpClient.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
})
}
// Create output file
out, err := os.Create(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create file: %v", err),
})
}
defer out.Close()
// Get content length for progress
contentLength := resp.ContentLength
// Copy content with progress reporting
var written int64
buf := make([]byte, 32*1024) // 32KB buffer
for {
nr, er := resp.Body.Read(buf)
if nr > 0 {
nw, ew := out.Write(buf[0:nr])
if nw < 0 || nr < nw {
nw = 0
if ew == nil {
ew = fmt.Errorf("invalid write result")
}
}
written += int64(nw)
if ew != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to write file: %v", ew),
})
}
if nr != nw {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "short write",
})
}
// Report progress
if onProgress != nil && contentLength > 0 {
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
}
}
if er != nil {
if er != io.EOF {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read response: %v", er),
})
}
break
}
}
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullPath,
"size": written,
})
}
// fileExists checks if a file exists in the sandbox
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(false)
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(false)
}
_, err = os.Stat(fullPath)
return r.vm.ToValue(err == nil)
}
// fileDelete deletes a file in the sandbox
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
if err := os.Remove(fullPath); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
})
}
// fileRead reads a file from the sandbox
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
data, err := os.ReadFile(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": string(data),
})
}
// fileWrite writes data to a file in the sandbox
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path and data are required",
})
}
path := call.Arguments[0].String()
data := call.Arguments[1].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
// Create directory if needed
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullPath,
})
}
// fileCopy copies a file within the sandbox
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "source and destination paths are required",
})
}
srcPath := call.Arguments[0].String()
dstPath := call.Arguments[1].String()
fullSrc, err := r.validatePath(srcPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
fullDst, err := r.validatePath(dstPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
// Read source file
data, err := os.ReadFile(fullSrc)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read source: %v", err),
})
}
// Create destination directory if needed
dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
// Write to destination
if err := os.WriteFile(fullDst, data, 0644); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to write destination: %v", err),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullDst,
})
}
// fileMove moves/renames a file within the sandbox
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "source and destination paths are required",
})
}
srcPath := call.Arguments[0].String()
dstPath := call.Arguments[1].String()
fullSrc, err := r.validatePath(srcPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
fullDst, err := r.validatePath(dstPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
// Create destination directory if needed
dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
if err := os.Rename(fullSrc, fullDst); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to move file: %v", err),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullDst,
})
}
// fileGetSize returns the size of a file in bytes
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
info, err := os.Stat(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"size": info.Size(),
})
}
+505
View File
@@ -0,0 +1,505 @@
// Package gobackend provides HTTP API for extension runtime
package gobackend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/dop251/goja"
)
// ==================== 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()
// Block private/local network access (SSRF protection)
if isPrivateIP(domain) {
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
}
if !r.manifest.IsDomainAllowed(domain) {
return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain)
}
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{}{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// Get headers if provided
headers := make(map[string]string)
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
headersObj := call.Arguments[1].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
// Create request
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// 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{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// 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
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience
"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{}{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// 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()
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{}{
"error": fmt.Sprintf("failed to stringify body: %v", err),
})
}
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()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
// Create request
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// 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")
}
if req.Header.Get("Content-Type") == "" {
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{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// 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
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
})
}
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
// Usage: http.request(url, options) where options = { method, body, headers }
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// 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{}{
"error": fmt.Sprintf("failed to stringify body: %v", err),
})
}
bodyStr = string(jsonBytes)
default:
bodyStr = fmt.Sprintf("%v", v)
}
}
// Get headers
if h, ok := opts["headers"].(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
}
// Create request
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
}
req, err := http.NewRequest(method, urlStr, reqBody)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// 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")
}
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
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{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// 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
}
}
// Return response with helper properties
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode, // Alias for convenience
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
})
}
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call)
}
// 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)
}
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call)
}
// 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{}{
"error": "URL is required",
})
}
urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
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 {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
} 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) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": fmt.Sprintf("failed to stringify body: %v", err),
})
}
bodyStr = string(jsonBytes)
default:
bodyStr = call.Arguments[1].String()
}
}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
headersObj := call.Arguments[2].Export()
if h, ok := headersObj.(map[string]interface{}); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
}
// Create request
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
}
req, err := http.NewRequest(method, urlStr, reqBody)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// Set headers - user headers first
for k, v := range headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
}
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
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{}{
"error": err.Error(),
})
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
// Extract response 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 r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"body": string(body),
"headers": respHeaders,
})
}
// httpClearCookies clears all cookies for this extension
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
jar.mu.Lock()
jar.cookies = make(map[string][]*http.Cookie)
jar.mu.Unlock()
GoLog("[Extension:%s] Cookies cleared\n", r.extensionID)
return r.vm.ToValue(true)
}
return r.vm.ToValue(false)
}
+151
View File
@@ -0,0 +1,151 @@
// Package gobackend provides Track Matching API for extension runtime
package gobackend
import (
"strings"
"github.com/dop251/goja"
)
// ==================== 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)
}
str1 := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
str2 := strings.ToLower(strings.TrimSpace(call.Arguments[1].String()))
if str1 == str2 {
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)
}
dur1 := int(call.Arguments[0].ToInteger())
dur2 := int(call.Arguments[1].ToInteger())
// Default tolerance: 3 seconds
tolerance := 3000 // milliseconds
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
tolerance = int(call.Arguments[2].ToInteger())
}
diff := dur1 - dur2
if diff < 0 {
diff = -diff
}
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("")
}
str := call.Arguments[0].String()
normalized := normalizeStringForMatching(str)
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
}
if len(s1) == 0 || len(s2) == 0 {
return 0.0
}
// Use Levenshtein distance
distance := levenshteinDistance(s1, s2)
maxLen := len(s1)
if len(s2) > maxLen {
maxLen = len(s2)
}
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)
}
if len(s2) == 0 {
return len(s1)
}
// Create matrix
matrix := make([][]int, len(s1)+1)
for i := range matrix {
matrix[i] = make([]int, len(s2)+1)
matrix[i][0] = i
}
for j := range matrix[0] {
matrix[0][j] = j
}
// Fill matrix
for i := 1; i <= len(s1); i++ {
for j := 1; j <= len(s2); j++ {
cost := 1
if s1[i-1] == s2[j-1] {
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
)
}
}
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",
" (explicit)", " (clean)", " [explicit]", " [clean]",
" (album version)", " (single version)", " (radio edit)",
" (feat.", " (ft.", " feat.", " ft.",
}
for _, suffix := range suffixes {
if idx := strings.Index(s, suffix); idx != -1 {
s = s[:idx]
}
}
// Remove special characters
var result strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
result.WriteRune(r)
}
}
// Collapse multiple spaces
s = strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(s)
}
+488
View File
@@ -0,0 +1,488 @@
// Package gobackend provides Browser-like Polyfills for extension runtime
package gobackend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/dop251/goja"
)
// ==================== Browser-like Polyfills ====================
// These polyfills make porting browser/Node.js libraries easier
// without compromising sandbox security
// fetchPolyfill implements browser-compatible fetch() API
// Returns a Promise-like object with json(), text() methods
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.createFetchError("URL is required")
}
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)
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)
}
// Body - support string, object (auto-stringify), or nil
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
switch v := bodyArg.(type) {
case string:
bodyStr = v
case map[string]interface{}, []interface{}:
jsonBytes, err := json.Marshal(v)
if err != nil {
return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err))
}
bodyStr = string(jsonBytes)
default:
bodyStr = fmt.Sprintf("%v", v)
}
}
// Headers
if h, ok := opts["headers"]; ok && h != nil {
switch hv := h.(type) {
case map[string]interface{}:
for k, v := range hv {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
}
}
// Create HTTP request
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
}
req, err := http.NewRequest(method, urlStr, reqBody)
if err != nil {
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")
}
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
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 {
respHeaders[k] = v[0]
} else {
respHeaders[k] = v
}
}
// Create Response object (browser-compatible)
responseObj := r.vm.NewObject()
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
responseObj.Set("status", resp.StatusCode)
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
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 {
GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err)
return goja.Undefined()
}
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)
}
return r.vm.ToValue(byteArray)
})
return responseObj
}
// createFetchError creates a fetch error response
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
errorObj := r.vm.NewObject()
errorObj.Set("ok", false)
errorObj.Set("status", 0)
errorObj.Set("statusText", "Network Error")
errorObj.Set("error", message)
errorObj.Set("text", func(call goja.FunctionCall) goja.Value {
return r.vm.ToValue("")
})
errorObj.Set("json", func(call goja.FunctionCall) goja.Value {
return goja.Undefined()
})
return errorObj
}
// atobPolyfill implements browser atob() - decode base64 to string
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
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)
return r.vm.ToValue("")
}
}
return r.vm.ToValue(string(decoded))
}
// btoaPolyfill implements browser btoa() - encode string to base64
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
input := call.Arguments[0].String()
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
}
// 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{})
}
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)
}
return vm.ToValue(result)
})
// encodeInto() method
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
// Simplified implementation
if len(call.Arguments) < 2 {
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
}
input := call.Arguments[0].String()
return vm.ToValue(map[string]interface{}{
"read": len(input),
"written": len([]byte(input)),
})
})
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()
}
decoder.Set("encoding", encoding)
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
switch v := input.(type) {
case []byte:
bytes = v
case []interface{}:
bytes = make([]byte, len(v))
for i, val := range v {
switch n := val.(type) {
case int64:
bytes[i] = byte(n)
case float64:
bytes[i] = byte(n)
case int:
bytes[i] = byte(n)
}
}
case string:
// Already a string, just return it
return vm.ToValue(v)
default:
return vm.ToValue("")
}
return vm.ToValue(string(bytes))
})
return nil
})
}
// 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
if len(call.Arguments) < 1 {
urlObj.Set("href", "")
return nil
}
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)
if err == nil {
relURL, err := url.Parse(urlStr)
if err == nil {
urlStr = baseURL.ResolveReference(relURL).String()
}
}
}
parsed, err := url.Parse(urlStr)
if err != nil {
urlObj.Set("href", urlStr)
return nil
}
// Set URL properties
urlObj.Set("href", parsed.String())
urlObj.Set("protocol", parsed.Scheme+":")
urlObj.Set("host", parsed.Host)
urlObj.Set("hostname", parsed.Hostname())
urlObj.Set("port", parsed.Port())
urlObj.Set("pathname", parsed.Path)
urlObj.Set("search", "")
if parsed.RawQuery != "" {
urlObj.Set("search", "?"+parsed.RawQuery)
}
urlObj.Set("hash", "")
if parsed.Fragment != "" {
urlObj.Set("hash", "#"+parsed.Fragment)
}
urlObj.Set("origin", parsed.Scheme+"://"+parsed.Host)
urlObj.Set("username", parsed.User.Username())
password, _ := parsed.User.Password()
urlObj.Set("password", password)
// searchParams object
searchParams := vm.NewObject()
queryValues := parsed.Query()
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Null()
}
key := call.Arguments[0].String()
if val := queryValues.Get(key); val != "" {
return vm.ToValue(val)
}
return goja.Null()
})
searchParams.Set("getAll", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue([]string{})
}
key := call.Arguments[0].String()
return vm.ToValue(queryValues[key])
})
searchParams.Set("has", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue(false)
}
key := call.Arguments[0].String()
return vm.ToValue(queryValues.Has(key))
})
searchParams.Set("toString", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(queryValues.Encode())
})
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())
})
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{}:
for k, val := range v {
values.Set(k, fmt.Sprintf("%v", val))
}
}
}
paramsObj.Set("append", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) >= 2 {
values.Add(call.Arguments[0].String(), call.Arguments[1].String())
}
return goja.Undefined()
})
paramsObj.Set("delete", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) >= 1 {
values.Del(call.Arguments[0].String())
}
return goja.Undefined()
})
paramsObj.Set("get", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Null()
}
if val := values.Get(call.Arguments[0].String()); val != "" {
return vm.ToValue(val)
}
return goja.Null()
})
paramsObj.Set("getAll", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue([]string{})
}
return vm.ToValue(values[call.Arguments[0].String()])
})
paramsObj.Set("has", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue(false)
}
return vm.ToValue(values.Has(call.Arguments[0].String()))
})
paramsObj.Set("set", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) >= 2 {
values.Set(call.Arguments[0].String(), call.Arguments[1].String())
}
return goja.Undefined()
})
paramsObj.Set("toString", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(values.Encode())
})
return nil
})
}
// 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 = {
parse: function(text) {
return utils.parseJSON(text);
},
stringify: function(value, replacer, space) {
return utils.stringifyJSON(value);
}
};
}
`
_, _ = vm.RunString(jsonScript)
}
+381
View File
@@ -0,0 +1,381 @@
// Package gobackend provides Storage and Credentials API for extension runtime
package gobackend
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"github.com/dop251/goja"
)
// ==================== 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)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]interface{}), nil
}
return nil, err
}
var storage map[string]interface{}
if err := json.Unmarshal(data, &storage); err != nil {
return nil, err
}
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, "", " ")
if err != nil {
return err
}
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()
}
key := call.Arguments[0].String()
storage, err := r.loadStorage()
if err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
return goja.Undefined()
}
value, exists := storage[key]
if !exists {
// Return default value if provided
if len(call.Arguments) > 1 {
return call.Arguments[1]
}
return goja.Undefined()
}
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)
}
key := call.Arguments[0].String()
value := call.Arguments[1].Export()
storage, err := r.loadStorage()
if err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
storage[key] = value
if err := r.saveStorage(storage); err != nil {
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
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)
}
key := call.Arguments[0].String()
storage, err := r.loadStorage()
if err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
delete(storage, key)
if err := r.saveStorage(storage); err != nil {
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
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()
// Try to read existing salt
salt, err := os.ReadFile(saltPath)
if err == nil && len(salt) == 32 {
return salt, nil
}
// Generate new random salt (32 bytes)
salt = make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err)
}
// Save salt to file
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
return nil, fmt.Errorf("failed to save salt: %w", err)
}
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)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]interface{}), nil
}
return nil, err
}
// Decrypt the data
key, err := r.getEncryptionKey()
if err != nil {
return nil, fmt.Errorf("failed to get encryption key: %w", err)
}
decrypted, err := decryptAES(data, key)
if err != nil {
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
}
var creds map[string]interface{}
if err := json.Unmarshal(decrypted, &creds); err != nil {
return nil, err
}
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 {
return err
}
// Encrypt the data
key, err := r.getEncryptionKey()
if err != nil {
return fmt.Errorf("failed to get encryption key: %w", err)
}
encrypted, err := encryptAES(data, key)
if err != nil {
return fmt.Errorf("failed to encrypt credentials: %w", err)
}
credPath := r.getCredentialsPath()
return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions
}
// 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{}{
"success": false,
"error": "key and value are required",
})
}
key := call.Arguments[0].String()
value := call.Arguments[1].Export()
creds, err := r.loadCredentials()
if err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
creds[key] = value
if err := r.saveCredentials(creds); err != nil {
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
})
}
// credentialsGet retrieves a decrypted credential
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Undefined()
}
key := call.Arguments[0].String()
creds, err := r.loadCredentials()
if err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return goja.Undefined()
}
value, exists := creds[key]
if !exists {
// Return default value if provided
if len(call.Arguments) > 1 {
return call.Arguments[1]
}
return goja.Undefined()
}
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)
}
key := call.Arguments[0].String()
creds, err := r.loadCredentials()
if err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
delete(creds, key)
if err := r.saveCredentials(creds); err != nil {
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
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)
}
key := call.Arguments[0].String()
creds, err := r.loadCredentials()
if err != nil {
return r.vm.ToValue(false)
}
_, exists := creds[key]
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 {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
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 {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
+372
View File
@@ -0,0 +1,372 @@
// Package gobackend provides Utility functions for extension runtime
package gobackend
import (
"crypto/hmac"
"crypto/md5"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"github.com/dop251/goja"
)
// ==================== 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("")
}
input := call.Arguments[0].String()
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("")
}
input := call.Arguments[0].String()
decoded, err := base64.StdEncoding.DecodeString(input)
if err != nil {
return r.vm.ToValue("")
}
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("")
}
input := call.Arguments[0].String()
hash := md5.Sum([]byte(input))
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("")
}
input := call.Arguments[0].String()
hash := sha256.Sum256([]byte(input))
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("")
}
message := call.Arguments[0].String()
key := call.Arguments[1].String()
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(message))
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("")
}
message := call.Arguments[0].String()
key := call.Arguments[1].String()
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(message))
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{})
}
// Get key - can be string or array of bytes
var keyBytes []byte
keyArg := call.Arguments[0].Export()
switch k := keyArg.(type) {
case string:
keyBytes = []byte(k)
case []interface{}:
keyBytes = make([]byte, len(k))
for i, v := range k {
if num, ok := v.(int64); ok {
keyBytes[i] = byte(num)
} else if num, ok := v.(float64); ok {
keyBytes[i] = byte(int(num))
}
}
default:
return r.vm.ToValue([]byte{})
}
// Get message - can be string or array of bytes
var msgBytes []byte
msgArg := call.Arguments[1].Export()
switch m := msgArg.(type) {
case string:
msgBytes = []byte(m)
case []interface{}:
msgBytes = make([]byte, len(m))
for i, v := range m {
if num, ok := v.(int64); ok {
msgBytes[i] = byte(num)
} else if num, ok := v.(float64); ok {
msgBytes[i] = byte(int(num))
}
}
default:
return r.vm.ToValue([]byte{})
}
mac := hmac.New(sha1.New, keyBytes)
mac.Write(msgBytes)
result := mac.Sum(nil)
// Convert to array of numbers for JavaScript
jsArray := make([]interface{}, len(result))
for i, b := range result {
jsArray[i] = int(b)
}
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()
}
input := call.Arguments[0].String()
var result interface{}
if err := json.Unmarshal([]byte(input), &result); err != nil {
GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err)
return goja.Undefined()
}
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("")
}
input := call.Arguments[0].Export()
data, err := json.Marshal(input)
if err != nil {
GoLog("[Extension:%s] JSON stringify error: %v\n", r.extensionID, err)
return r.vm.ToValue("")
}
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{}{
"success": false,
"error": "plaintext and key are required",
})
}
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[:])
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": base64.StdEncoding.EncodeToString(encrypted),
})
}
// 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{}{
"success": false,
"error": "ciphertext and key are required",
})
}
ciphertextB64 := call.Arguments[0].String()
keyStr := call.Arguments[1].String()
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "invalid base64 ciphertext",
})
}
// 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(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": string(decrypted),
})
}
// cryptoGenerateKey generates a random encryption key
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
length := 32 // Default 256-bit key
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok {
length = int(l)
}
}
key := make([]byte, length)
if _, err := rand.Read(key); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"key": base64.StdEncoding.EncodeToString(key),
"hex": hex.EncodeToString(key),
})
}
// ==================== 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)
return goja.Undefined()
}
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
parts := make([]string, len(args))
for i, arg := range args {
parts[i] = fmt.Sprintf("%v", arg.Export())
}
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("")
}
input := call.Arguments[0].String()
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) {
gobackendObj = vm.NewObject()
vm.Set("gobackend", gobackendObj)
}
obj := gobackendObj.(*goja.Object)
// Expose sanitizeFilename
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue("")
}
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{}{
"error": "file path is required",
})
}
filePath := call.Arguments[0].String()
quality, err := GetAudioQuality(filePath)
if err != nil {
return vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
return vm.ToValue(map[string]interface{}{
"bitDepth": quality.BitDepth,
"sampleRate": quality.SampleRate,
"totalSamples": quality.TotalSamples,
})
})
// Expose buildFilename
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return vm.ToValue("")
}
template := call.Arguments[0].String()
metadataObj := call.Arguments[1].Export()
metadata, ok := metadataObj.(map[string]interface{})
if !ok {
return vm.ToValue("")
}
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
})
}
+221
View File
@@ -0,0 +1,221 @@
// Package gobackend provides extension settings storage
package gobackend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"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{
settings: make(map[string]map[string]interface{}),
}
})
return globalSettingsStore
}
// SetDataDir sets the data directory for settings storage
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.dataDir = dataDir
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("failed to create settings directory: %w", err)
}
// Load all existing settings
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 {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() {
extensionID := entry.Name()
settings, err := s.loadSettings(extensionID)
if err != nil {
GoLog("[ExtensionSettings] Failed to load settings for %s: %v\n", extensionID, err)
continue
}
s.settings[extensionID] = settings
}
}
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)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]interface{}), nil
}
return nil, err
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err != nil {
return nil, err
}
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)
// Create directory if needed
dir := filepath.Dir(settingsPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
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()
extSettings, exists := s.settings[extensionID]
if !exists {
return nil, fmt.Errorf("extension '%s' settings not found", extensionID)
}
value, exists := extSettings[key]
if !exists {
return nil, fmt.Errorf("setting '%s' not found for extension '%s'", key, extensionID)
}
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()
extSettings, exists := s.settings[extensionID]
if !exists {
return make(map[string]interface{})
}
// Return a copy
result := make(map[string]interface{})
for k, v := range extSettings {
result[k] = v
}
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()
if _, exists := s.settings[extensionID]; !exists {
s.settings[extensionID] = make(map[string]interface{})
}
s.settings[extensionID][key] = value
// Persist to disk
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()
extSettings, exists := s.settings[extensionID]
if !exists {
return nil
}
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()
delete(s.settings, extensionID)
// Remove settings file
settingsPath := s.getSettingsPath(extensionID)
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// GetAllExtensionSettings returns settings for all extensions as JSON
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := json.Marshal(s.settings)
if err != nil {
return "", err
}
return string(data), nil
}
+453
View File
@@ -0,0 +1,453 @@
package gobackend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
// Extension categories
const (
CategoryMetadata = "metadata"
CategoryDownload = "download"
CategoryUtility = "utility"
CategoryLyrics = "lyrics"
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"`
// Alternative camelCase fields (for flexibility)
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
}
if e.DisplayNameAlt != "" {
return e.DisplayNameAlt
}
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
}
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
}
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
}
return e.MinAppVersionAlt
}
// StoreRegistry represents the extension registry
type StoreRegistry struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at"`
Extensions []StoreExtension `json:"extensions"`
}
// StoreExtensionResponse is the normalized response sent to Flutter
type StoreExtensionResponse struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url"`
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"`
IsInstalled bool `json:"is_installed"`
InstalledVersion string `json:"installed_version,omitempty"`
HasUpdate bool `json:"has_update"`
}
// ToResponse converts StoreExtension to normalized response
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
return StoreExtensionResponse{
ID: e.ID,
Name: e.Name,
DisplayName: e.getDisplayName(),
Version: e.Version,
Author: e.Author,
Description: e.Description,
DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(),
Category: e.Category,
Tags: e.Tags,
Downloads: e.Downloads,
UpdatedAt: e.UpdatedAt,
MinAppVersion: e.getMinAppVersion(),
}
}
// ExtensionStore manages the extension store
type ExtensionStore struct {
registryURL string
cacheDir string
cache *StoreRegistry
cacheMu sync.RWMutex
cacheTime time.Time
cacheTTL time.Duration
}
var (
extensionStore *ExtensionStore
extensionStoreMu sync.Mutex
)
const (
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
cacheTTL = 30 * time.Minute
cacheFileName = "store_cache.json"
)
// InitExtensionStore initializes the extension store
func InitExtensionStore(cacheDir string) *ExtensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
if extensionStore == nil {
extensionStore = &ExtensionStore{
registryURL: defaultRegistryURL,
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
}
cachePath := filepath.Join(s.cacheDir, cacheFileName)
data, err := os.ReadFile(cachePath)
if err != nil {
return
}
var cacheData struct {
Registry StoreRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}
if err := json.Unmarshal(data, &cacheData); err != nil {
return
}
s.cache = &cacheData.Registry
s.cacheTime = time.Unix(cacheData.CacheTime, 0)
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
}
cacheData := struct {
Registry StoreRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}{
Registry: *s.cache,
CacheTime: s.cacheTime.Unix(),
}
data, err := json.Marshal(cacheData)
if err != nil {
return
}
cachePath := filepath.Join(s.cacheDir, cacheFileName)
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
}
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
}
return nil, fmt.Errorf("failed to fetch registry: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if s.cache != nil {
LogWarn("ExtensionStore", "HTTP %d, using cached registry", resp.StatusCode)
return s.cache, nil
}
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read registry: %w", err)
}
var registry StoreRegistry
if err := json.Unmarshal(body, &registry); err != nil {
return nil, fmt.Errorf("failed to parse registry: %w", err)
}
s.cache = &registry
s.cacheTime = time.Now()
s.saveDiskCache()
LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions))
return &registry, nil
}
// GetExtensionsWithStatus returns extensions with installation status
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(false)
if err != nil {
return nil, err
}
manager := GetExtensionManager()
installed := make(map[string]string) // id -> version
if manager != nil {
for _, ext := range manager.GetAllExtensions() {
installed[ext.ID] = ext.Manifest.Version
}
}
result := make([]StoreExtensionResponse, len(registry.Extensions))
for i, ext := range registry.Extensions {
resp := ext.ToResponse()
if installedVersion, ok := installed[ext.ID]; ok {
resp.IsInstalled = true
resp.InstalledVersion = installedVersion
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
}
result[i] = resp
}
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 {
return err
}
var ext *StoreExtension
for _, e := range registry.Extensions {
if e.ID == extensionID {
ext = &e
break
}
}
if ext == nil {
return fmt.Errorf("extension %s not found in store", extensionID)
}
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Get(ext.getDownloadURL())
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
// Create destination file
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
os.Remove(destPath)
return fmt.Errorf("failed to write file: %w", err)
}
LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath)
return nil
}
// GetCategories returns all available categories
func (s *ExtensionStore) GetCategories() []string {
return []string{
CategoryMetadata,
CategoryDownload,
CategoryUtility,
CategoryLyrics,
CategoryIntegration,
}
}
// SearchExtensions searches extensions by query
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus()
if err != nil {
return nil, err
}
if query == "" && category == "" {
return extensions, nil
}
var result []StoreExtensionResponse
queryLower := toLower(query)
for _, ext := range extensions {
// Filter by category
if category != "" && ext.Category != category {
continue
}
// Filter by query
if query != "" {
if !containsIgnoreCase(ext.Name, queryLower) &&
!containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) &&
!containsIgnoreCase(ext.Author, queryLower) {
// Check tags
found := false
for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) {
found = true
break
}
}
if !found {
continue
}
}
}
result = append(result, ext)
}
return result, nil
}
// ClearCache clears the in-memory and disk cache
func (s *ExtensionStore) ClearCache() {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
s.cache = nil
s.cacheTime = time.Time{}
if s.cacheDir != "" {
cachePath := filepath.Join(s.cacheDir, cacheFileName)
os.Remove(cachePath)
}
LogInfo("ExtensionStore", "Cache cleared")
}
// Helper: case-insensitive contains
func containsIgnoreCase(s, substr string) bool {
return containsStr(toLower(s), substr)
}
func toLower(s string) string {
result := make([]byte, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
result[i] = c
}
return string(result)
}
func containsStr(s, substr string) bool {
return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0)
}
func findSubstring(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
+329
View File
@@ -0,0 +1,329 @@
package gobackend
import (
"path/filepath"
"testing"
"github.com/dop251/goja"
)
func TestParseManifest_Valid(t *testing.T) {
validManifest := `{
"name": "test-provider",
"displayName": "Test Provider",
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension",
"type": ["metadata_provider"],
"permissions": {
"network": ["api.test.com"],
"storage": true
}
}`
manifest, err := ParseManifest([]byte(validManifest))
if err != nil {
t.Fatalf("Expected valid manifest to parse, got error: %v", err)
}
if manifest.Name != "test-provider" {
t.Errorf("Expected name 'test-provider', got '%s'", manifest.Name)
}
if manifest.Version != "1.0.0" {
t.Errorf("Expected version '1.0.0', got '%s'", manifest.Version)
}
if !manifest.IsMetadataProvider() {
t.Error("Expected IsMetadataProvider() to return true")
}
if manifest.IsDownloadProvider() {
t.Error("Expected IsDownloadProvider() to return false")
}
}
func TestParseManifest_MissingName(t *testing.T) {
invalidManifest := `{
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension",
"type": ["metadata_provider"]
}`
_, err := ParseManifest([]byte(invalidManifest))
if err == nil {
t.Fatal("Expected error for missing name")
}
}
func TestParseManifest_MissingType(t *testing.T) {
invalidManifest := `{
"name": "test-provider",
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension"
}`
_, err := ParseManifest([]byte(invalidManifest))
if err == nil {
t.Fatal("Expected error for missing type")
}
}
func TestIsDomainAllowed(t *testing.T) {
manifest := &ExtensionManifest{
Permissions: ExtensionPermissions{
Network: []string{"api.test.com", "*.example.com"},
},
}
tests := []struct {
domain string
expected bool
}{
{"api.test.com", true},
{"api.example.com", true},
{"sub.example.com", true},
{"notallowed.com", false},
{"test.com", false},
}
for _, tt := range tests {
result := manifest.IsDomainAllowed(tt.domain)
if result != tt.expected {
t.Errorf("IsDomainAllowed(%s) = %v, expected %v", tt.domain, result, tt.expected)
}
}
}
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
// Create a mock extension with limited network permissions
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
Network: []string{"api.allowed.com", "*.wildcard.com"},
},
},
DataDir: t.TempDir(),
}
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)
}
if err := runtime.validateDomain("https://sub.wildcard.com/path"); err != nil {
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")
}
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
t.Error("Expected notallowed.com to be denied")
}
}
func TestExtensionRuntime_FileSandbox(t *testing.T) {
tempDir := t.TempDir()
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
File: true, // Enable file permission for test
},
},
DataDir: tempDir,
}
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)
}
if validPath == "" {
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)
}
if nestedPath == "" {
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
} else {
absPath = "/etc/passwd" // Unix
}
_, 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
},
},
DataDir: tempDir,
}
runtimeNoFile := NewExtensionRuntime(extNoFile)
_, err = runtimeNoFile.validatePath("test.txt")
if err == nil {
t.Error("Expected file access to be denied without file permission")
}
}
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
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)
}
if result.String() != "aGVsbG8=" {
t.Errorf("Expected 'aGVsbG8=', got '%s'", result.String())
}
result, err = vm.RunString(`utils.base64Decode("aGVsbG8=")`)
if err != nil {
t.Fatalf("base64Decode failed: %v", err)
}
if result.String() != "hello" {
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)
}
if result.String() != "5d41402abc4b2a76b9719d911017c592" {
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)
}
// JSON output may vary in order, just check it's valid
if result.String() == "" {
t.Error("Expected non-empty JSON string")
}
}
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
// Create extension with limited network permissions
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
Network: []string{"api.example.com"},
},
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
// Test that private IPs are blocked (SSRF protection)
privateIPs := []string{
"http://localhost/admin",
"http://127.0.0.1/admin",
"http://192.168.1.1/admin",
"http://10.0.0.1/admin",
"http://172.16.0.1/admin",
"http://169.254.169.254/latest/meta-data/", // AWS metadata
"http://router.local/admin",
}
for _, url := range privateIPs {
err := runtime.validateDomain(url)
if err == nil {
t.Errorf("Expected private IP/host '%s' to be blocked", url)
}
}
// 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)
}
}
func TestIsPrivateIP(t *testing.T) {
tests := []struct {
host string
expected bool
}{
// Private IPs should be blocked
{"localhost", true},
{"127.0.0.1", true},
{"127.0.0.2", true},
{"10.0.0.1", true},
{"10.255.255.255", true},
{"172.16.0.1", true},
{"172.31.255.255", true},
{"192.168.0.1", true},
{"192.168.255.255", true},
{"169.254.169.254", true}, // AWS metadata
{"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
}
for _, tt := range tests {
result := isPrivateIP(tt.host)
if result != tt.expected {
t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.host, result, tt.expected)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
// Package gobackend provides timeout execution for extension JS code
package gobackend
import (
"context"
"fmt"
"sync"
"time"
"github.com/dop251/goja"
)
// JSExecutionError represents an error during JS execution
type JSExecutionError struct {
Message string
IsTimeout bool
}
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
}
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()
if wasInterrupted {
resultCh <- result{nil, &JSExecutionError{
Message: "execution timeout exceeded",
IsTimeout: true,
}}
} else {
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
}
}
}()
val, err := vm.RunString(script)
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
}
return nil, &JSExecutionError{
Message: "execution timeout exceeded",
IsTimeout: true,
}
case <-time.After(1 * time.Second):
// Force return timeout error
return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)",
IsTimeout: true,
}
}
}
}
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
// This should be used when you want to continue using the VM after a timeout
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
result, err := RunWithTimeout(vm, script, timeout)
// Clear any interrupt state so VM can be reused
vm.ClearInterrupt()
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
}
return false
}
+5
View File
@@ -5,14 +5,19 @@ go 1.24.0
toolchain go1.24.5
require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0
)
require (
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.3.8 // indirect
golang.org/x/tools v0.40.0 // indirect
)
+14
View File
@@ -1,14 +1,28 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+55 -56
View File
@@ -22,12 +22,12 @@ import (
func getRandomUserAgent() string {
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
// Some APIs may block mobile User-Agents, so we use desktop format
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
chromePatch := rand.Intn(65) + 60 // Patch 60-125
chromePatch := rand.Intn(65) + 60 // Patch 60-125
return fmt.Sprintf(
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
winMajor,
@@ -39,46 +39,48 @@ func getRandomUserAgent() string {
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
// Alternative format matching referensi/backend/spotify_metadata.go exactly
func getRandomMacUserAgent() string {
macMajor := rand.Intn(4) + 11 // macOS 11-14
macMinor := rand.Intn(5) + 4 // Minor 4-8
webkitMajor := rand.Intn(7) + 530
webkitMinor := rand.Intn(7) + 30
chromeMajor := rand.Intn(25) + 80
chromeBuild := rand.Intn(1500) + 3000
chromePatch := rand.Intn(65) + 60
safariMajor := rand.Intn(7) + 530
safariMinor := rand.Intn(6) + 30
return fmt.Sprintf(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
macMajor,
macMinor,
webkitMajor,
webkitMinor,
chromeMajor,
chromeBuild,
chromePatch,
safariMajor,
safariMinor,
)
}
// Kept for potential future use
// func getRandomMacUserAgent() string {
// macMajor := rand.Intn(4) + 11 // macOS 11-14
// macMinor := rand.Intn(5) + 4 // Minor 4-8
// webkitMajor := rand.Intn(7) + 530
// webkitMinor := rand.Intn(7) + 30
// chromeMajor := rand.Intn(25) + 80
// chromeBuild := rand.Intn(1500) + 3000
// chromePatch := rand.Intn(65) + 60
// safariMajor := rand.Intn(7) + 530
// safariMinor := rand.Intn(6) + 30
//
// return fmt.Sprintf(
// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
// macMajor,
// macMinor,
// webkitMajor,
// webkitMinor,
// chromeMajor,
// chromeBuild,
// chromePatch,
// safariMajor,
// safariMinor,
// )
// }
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
func getRandomDesktopUserAgent() string {
if rand.Intn(2) == 0 {
return getRandomUserAgent() // Windows
}
return getRandomMacUserAgent() // Mac
}
// Kept for potential future use
// func getRandomDesktopUserAgent() string {
// if rand.Intn(2) == 0 {
// return getRandomUserAgent() // Windows
// }
// return getRandomMacUserAgent() // Mac
// }
// Default timeout values
const (
DefaultTimeout = 60 * time.Second // Default HTTP timeout
DownloadTimeout = 120 * time.Second // Timeout for file downloads
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
DefaultMaxRetries = 3 // Default retry count
DefaultRetryDelay = 1 * time.Second // Initial retry delay
DefaultTimeout = 60 * time.Second // Default HTTP timeout
DownloadTimeout = 120 * time.Second // Timeout for file downloads
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
DefaultMaxRetries = 3 // Default retry count
DefaultRetryDelay = 1 * time.Second // Initial retry delay
)
// Shared transport with connection pooling to prevent TCP exhaustion
@@ -96,9 +98,9 @@ var sharedTransport = &http.Transport{
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024, // 64KB write buffer
ReadBufferSize: 64 * 1024, // 64KB read buffer
DisableCompression: true, // FLAC is already compressed
WriteBufferSize: 64 * 1024, // 64KB write buffer
ReadBufferSize: 64 * 1024, // 64KB read buffer
DisableCompression: true, // FLAC is already compressed
}
// Shared HTTP client for general requests (reuses connections)
@@ -184,15 +186,15 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
resp, err := client.Do(reqCopy)
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")
}
if attempt < config.MaxRetries {
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
attempt+1, config.MaxRetries+1, err, delay)
time.Sleep(delay)
delay = calculateNextDelay(delay, config)
@@ -227,13 +229,13 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
bodyStr := strings.ToLower(string(body))
// Check if response looks like ISP blocking page
ispBlockingIndicators := []string{
"blocked", "forbidden", "access denied", "not available in your",
"restricted", "censored", "unavailable for legal", "blocked by",
}
for _, indicator := range ispBlockingIndicators {
if strings.Contains(bodyStr, indicator) {
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
@@ -267,10 +269,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
// calculateNextDelay calculates the next delay with exponential backoff
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
if nextDelay > config.MaxDelay {
nextDelay = config.MaxDelay
}
return nextDelay
return min(nextDelay, config.MaxDelay)
}
// getRetryAfterDuration parses Retry-After header and returns duration
@@ -481,7 +480,7 @@ func extractDomain(rawURL string) string {
if rawURL == "" {
return "unknown"
}
parsed, err := url.Parse(rawURL)
if err != nil {
// Try to extract domain manually
@@ -492,7 +491,7 @@ func extractDomain(rawURL string) string {
}
return rawURL
}
if parsed.Host != "" {
return parsed.Host
}
@@ -505,11 +504,11 @@ func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil {
return nil
}
if CheckAndLogISPBlocking(err, requestURL, tag) {
domain := extractDomain(requestURL)
return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err)
}
return err
}
+2 -2
View File
@@ -33,8 +33,8 @@ var (
func GetLogBuffer() *LogBuffer {
logBufferOnce.Do(func() {
globalLogBuffer = &LogBuffer{
entries: make([]LogEntry, 0, 500),
maxSize: 500,
entries: make([]LogEntry, 0, 1000),
maxSize: 1000,
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
}
})
+24 -23
View File
@@ -250,29 +250,30 @@ func msToLRCTimestamp(ms int64) string {
// convertToLRC converts lyrics to LRC format string (without metadata headers)
// Use convertToLRCWithMetadata for full LRC with headers
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()
}
// Kept for potential future use
// func convertToLRC(lyrics *LyricsResponse) string {
// if lyrics == nil || len(lyrics.Lines) == 0 {
// return ""
// }
//
// var builder strings.Builder
//
// if lyrics.SyncType == "LINE_SYNCED" {
// for _, line := range lyrics.Lines {
// timestamp := msToLRCTimestamp(line.StartTimeMs)
// builder.WriteString(timestamp)
// builder.WriteString(line.Words)
// builder.WriteString("\n")
// }
// } else {
// for _, line := range lyrics.Lines {
// builder.WriteString(line.Words)
// builder.WriteString("\n")
// }
// }
//
// return builder.String()
// }
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
// Includes [ti:], [ar:], [by:] headers
+4 -4
View File
@@ -498,7 +498,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
}
// Find udta atom inside moov, or create one
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
udtaPos := findAtom(data, "udta", moovPos+8)
// Build new metadata atoms
@@ -507,12 +507,12 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
var newData []byte
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
// udta exists, find meta inside it or replace
udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
metaPos := findAtom(data, "meta", udtaPos+8)
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
// Replace existing meta atom
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
newData = append(newData, data[:metaPos]...)
newData = append(newData, metaAtom...)
newData = append(newData, data[metaPos+metaSize:]...)
@@ -570,7 +570,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
// findAtom finds an atom by name starting from offset
func findAtom(data []byte, name string, offset int) int {
for i := offset; i < len(data)-8; {
size := int(data[i]<<24 | data[i+1]<<16 | data[i+2]<<8 | data[i+3])
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
if size < 8 {
break
}
+2 -2
View File
@@ -233,7 +233,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
}
func preWarmTidalCache(isrc, trackName, artistName string) {
func preWarmTidalCache(isrc, _, _ string) {
downloader := NewTidalDownloader()
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
@@ -272,7 +272,7 @@ func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest
// Parse JSON (simplified - in production use proper JSON parsing)
// For now, this is called from exports.go with proper parsing
go PreWarmTrackCache(requests) // Run in background
return nil
}
+11 -7
View File
@@ -23,7 +23,7 @@ type ItemProgress struct {
ItemID string `json:"item_id"`
BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"`
Progress float64 `json:"progress"` // 0.0 to 1.0
Progress float64 `json:"progress"` // 0.0 to 1.0
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed"
@@ -204,11 +204,12 @@ func setDownloadDir(path string) error {
}
// getDownloadDir returns the default download directory
func getDownloadDir() string {
downloadDirMu.RLock()
defer downloadDirMu.RUnlock()
return downloadDir
}
// Kept for potential future use
// func getDownloadDir() string {
// downloadDirMu.RLock()
// defer downloadDirMu.RUnlock()
// return downloadDir
// }
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
type ItemProgressWriter struct {
@@ -239,6 +240,9 @@ 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
}
n, err := pw.writer.Write(p)
if err != nil {
return n, err
@@ -256,7 +260,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
bytesInInterval := pw.current - pw.lastBytes
speedMBps = float64(bytesInInterval) / (1024 * 1024) / elapsed
}
SetItemBytesReceivedWithSpeed(pw.itemID, pw.current, speedMBps)
pw.lastReported = pw.current
pw.lastTime = now
+256 -80
View File
@@ -1,9 +1,11 @@
package gobackend
import (
"context"
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -12,6 +14,7 @@ import (
"path/filepath"
"strings"
"sync"
"time"
)
// QobuzDownloader handles Qobuz downloads
@@ -63,24 +66,27 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst)
// Split expected artists by common separators (comma, feat, ft., &, and)
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
expectedArtists := qobuzSplitArtists(normExpected)
foundArtists := qobuzSplitArtists(normFound)
foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
// Check if ANY expected artist matches ANY found artist
for _, exp := range expectedArtists {
for _, fnd := range foundArtists {
if exp == fnd {
return true
}
// Also check contains for partial matches
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true
}
// Check same words different order
if qobuzSameWordsUnordered(exp, fnd) {
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true
}
}
}
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
@@ -95,6 +101,67 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return false
}
// qobuzSplitArtists splits artist string by common separators
func qobuzSplitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|")
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
normalized = strings.ReplaceAll(normalized, " ft ", "|")
normalized = strings.ReplaceAll(normalized, " & ", "|")
normalized = strings.ReplaceAll(normalized, " and ", "|")
normalized = strings.ReplaceAll(normalized, ", ", "|")
normalized = strings.ReplaceAll(normalized, " x ", "|")
parts := strings.Split(normalized, "|")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
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] {
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
}
if sortedB[i] > sortedB[j] {
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
}
}
}
for i := range sortedA {
if sortedA[i] != sortedB[i] {
return false
}
}
return true
}
// qobuzTitlesMatch checks if track titles are similar enough
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
@@ -271,14 +338,15 @@ func qobuzIsLatinScript(s string) bool {
}
// qobuzIsASCIIString checks if a string contains only ASCII characters
func qobuzIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// 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 {
@@ -301,6 +369,35 @@ func NewQobuzDownloader() *QobuzDownloader {
return globalQobuzDownloader
}
// GetTrackByID fetches track info directly by Qobuz track ID
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
// Qobuz API: /track/get?track_id=XXX
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
req, err := http.NewRequest("GET", trackURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("get track failed: HTTP %d", resp.StatusCode)
}
var track QobuzTrack
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
return nil, err
}
return &track, nil
}
// GetAvailableAPIs returns list of available Qobuz APIs
// Uses same APIs as PC version for compatibility
func (q *QobuzDownloader) GetAvailableAPIs() []string {
@@ -634,85 +731,132 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
}
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
// Uses same URL format as PC version: /api/stream?trackId={id}&quality={quality}
func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) {
// qobuzAPIResult holds the result from a parallel API request
type qobuzAPIResult struct {
apiURL string
downloadURL string
err error
duration time.Duration
}
// getQobuzDownloadURLParallel requests download URL from all APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available")
}
client := NewHTTPClientWithTimeout(DefaultTimeout)
retryConfig := DefaultRetryConfig()
var errors []string
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel...\n", len(apis))
resultChan := make(chan qobuzAPIResult, len(apis))
startTime := time.Now()
// Start all requests in parallel
for _, apiURL := range apis {
// All APIs now use same format: https://domain/api/stream?trackId={id}&quality={quality}
// The apiURL already includes the path, just append trackID and quality
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
go func(api string) {
reqStart := time.Now()
GoLog("[Qobuz] Trying: %s\n", reqURL)
client := &http.Client{
Timeout: 15 * time.Second,
}
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
resp, err := DoRequestWithRetry(client, req, retryConfig)
if err != nil {
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
body, err := ReadResponseBody(resp)
resp.Body.Close()
if err != nil {
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
continue
}
resp, err := client.Do(req)
if err != nil {
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
defer resp.Body.Close()
// Check if response is HTML (error page)
if len(body) > 0 && body[0] == '<' {
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "received HTML instead of JSON"))
continue
}
if resp.StatusCode != 200 {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
return
}
// Check for error in JSON response
var errorResp struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, errorResp.Error))
continue
}
body, err := io.ReadAll(resp.Body)
if err != nil {
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "invalid JSON: "+err.Error()))
continue
}
// 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
}
if result.URL != "" {
GoLog("[Qobuz] Got download URL from: %s\n", apiURL)
return apiURL, result.URL, nil
}
// Check for error in JSON response
var errorResp struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)}
return
}
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL in response"))
var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("invalid JSON: %v", err), duration: time.Since(reqStart)}
return
}
if result.URL != "" {
resultChan <- qobuzAPIResult{apiURL: api, downloadURL: result.URL, err: nil, duration: time.Since(reqStart)}
return
}
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("no download URL in response"), duration: time.Since(reqStart)}
}(apiURL)
}
// Collect results - return first success
var errors []string
for i := 0; i < len(apis); i++ {
result := <-resultChan
if result.err == nil {
GoLog("[Qobuz] [Parallel] ✓ Got response from %s in %v\n", result.apiURL, result.duration)
// Drain remaining results to avoid goroutine leaks
go func(remaining int) {
for j := 0; j < remaining; j++ {
<-resultChan
}
}(len(apis) - i - 1)
GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
return result.apiURL, result.downloadURL, nil
}
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
}
GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
}
// GetDownloadURL gets download URL for a track - tries APIs sequentially
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
apis := q.GetAvailableAPIs()
if len(apis) == 0 {
return "", fmt.Errorf("no Qobuz API available")
}
_, downloadURL, err := getQobuzDownloadURLSequential(apis, trackID, quality)
// Use parallel approach - request from all APIs simultaneously
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
if err != nil {
return "", err
}
@@ -722,19 +866,30 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
// 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)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err
}
defer resp.Body.Close()
@@ -774,6 +929,9 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
@@ -823,8 +981,23 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
var track *QobuzTrack
var err error
// STRATEGY 0: Use pre-fetched Qobuz ID from Odesli enrichment (highest priority)
if req.QobuzID != "" {
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
var trackID int64
if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
track, err = downloader.GetTrackByID(trackID)
if err != nil {
GoLog("[Qobuz] Failed to get track by Odesli ID %d: %v\n", trackID, err)
track = nil
} else if track != nil {
GoLog("[Qobuz] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
}
}
}
// OPTIMIZATION: Check cache first for track ID
if req.ISRC != "" {
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
@@ -938,6 +1111,9 @@ 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
}
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
+92 -69
View File
@@ -2,7 +2,6 @@ package gobackend
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -17,14 +16,14 @@ import (
)
const (
spotifyTokenURL = "https://accounts.spotify.com/api/token"
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
searchBaseURL = "https://api.spotify.com/v1/search"
spotifyTokenURL = "https://accounts.spotify.com/api/token"
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
searchBaseURL = "https://api.spotify.com/v1/search"
// Cache TTL settings
artistCacheTTL = 10 * time.Minute
searchCacheTTL = 5 * time.Minute
@@ -54,7 +53,7 @@ type SpotifyMetadataClient struct {
rng *rand.Rand
rngMu sync.Mutex
userAgent string
// Caches to reduce API calls
artistCache map[string]*cacheEntry // key: artistID
searchCache map[string]*cacheEntry // key: query+type
@@ -69,8 +68,10 @@ 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
// Pass empty strings to use default credentials
func SetSpotifyCredentials(clientID, clientSecret string) {
credentialsMu.Lock()
defer credentialsMu.Unlock()
@@ -78,39 +79,56 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
customClientSecret = clientSecret
}
// getCredentials returns the current credentials (custom or default)
func getCredentials() (string, string) {
// HasSpotifyCredentials checks if Spotify credentials are configured
func HasSpotifyCredentials() bool {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
// Check custom credentials first
if customClientID != "" && customClientSecret != "" {
return customClientID, customClientSecret
}
// Fall back to default credentials
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
if clientID == "" {
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
clientID = string(decoded)
}
return true
}
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
if clientSecret == "" {
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
clientSecret = string(decoded)
}
// Check environment variables
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
return true
}
return clientID, clientSecret
return false
}
// getCredentials returns the current credentials or error if not configured
func getCredentials() (string, string, error) {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
// Check custom credentials first
if customClientID != "" && customClientSecret != "" {
return customClientID, customClientSecret, nil
}
// Check environment variables
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
if clientID != "" && clientSecret != "" {
return clientID, clientSecret, nil
}
// No credentials available
return "", "", ErrNoSpotifyCredentials
}
// NewSpotifyMetadataClient creates a new Spotify client
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
src := rand.NewSource(time.Now().UnixNano())
// Returns error if credentials are not configured
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
// Get credentials - will error if not configured
clientID, clientSecret, err := getCredentials()
if err != nil {
return nil, err
}
// Get credentials (custom or default)
clientID, clientSecret := getCredentials()
src := rand.NewSource(time.Now().UnixNano())
c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
@@ -122,7 +140,7 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
albumCache: make(map[string]*cacheEntry),
}
c.userAgent = c.randomUserAgent()
return c
return c, nil
}
// TrackMetadata represents track information
@@ -140,6 +158,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
}
// AlbumTrackMetadata holds per-track info for album/playlist
@@ -159,6 +178,7 @@ type AlbumTrackMetadata struct {
ISRC string `json:"isrc"`
AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"`
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
}
// AlbumInfoMetadata holds album information
@@ -283,6 +303,7 @@ type albumSimplified struct {
Images []image `json:"images"`
ExternalURL externalURL `json:"external_urls"`
Artists []artist `json:"artists"`
AlbumType string `json:"album_type"` // album, single, compilation
}
type trackFull struct {
@@ -331,14 +352,14 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
}
searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit)
var response struct {
Tracks struct {
Items []trackFull `json:"items"`
Total int `json:"total"`
} `json:"tracks"`
}
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
return nil, err
}
@@ -363,6 +384,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
DiscNumber: track.DiscNumber,
ExternalURL: track.ExternalURL.Spotify,
ISRC: track.ExternalID.ISRC,
AlbumType: track.Album.AlbumType,
})
}
@@ -373,7 +395,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
// Create cache key
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
@@ -388,24 +410,24 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
}
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
var response struct {
Tracks struct {
Items []trackFull `json:"items"`
} `json:"tracks"`
Artists struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
Followers struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
Followers struct {
Total int `json:"total"`
} `json:"followers"`
Popularity int `json:"popularity"`
} `json:"items"`
} `json:"artists"`
}
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
return nil, err
}
@@ -430,6 +452,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
DiscNumber: track.DiscNumber,
ExternalURL: track.ExternalURL.Spotify,
ISRC: track.ExternalID.ISRC,
AlbumType: track.Album.AlbumType,
})
}
@@ -438,7 +461,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
if artistCount > artistLimit {
artistCount = artistLimit
}
for i := 0; i < artistCount; i++ {
artist := response.Artists.Items[i]
result.Artists = append(result.Artists, SearchArtistResult{
@@ -534,7 +557,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
// Collect all tracks (including paginated)
allTrackItems := data.Tracks.Items
nextURL := data.Tracks.Next
// Fetch remaining tracks using pagination (no limit)
for nextURL != "" {
var pageData struct {
@@ -563,7 +586,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
for _, item := range allTrackItems {
isrc := isrcMap[item.ID]
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: item.ID,
Artists: joinArtists(item.Artists),
@@ -602,23 +625,23 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
// Similar to Deezer implementation for consistency
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
const maxParallelISRC = 10 // Max concurrent ISRC fetches
result := make(map[string]string)
var resultMu sync.Mutex
if len(trackIDs) == 0 {
return result
}
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, maxParallelISRC)
var wg sync.WaitGroup
for _, trackID := range trackIDs {
wg.Add(1)
go func(id string) {
defer wg.Done()
// Acquire semaphore
select {
case sem <- struct{}{}:
@@ -626,15 +649,15 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
case <-ctx.Done():
return
}
isrc := c.fetchTrackISRC(ctx, id, token)
resultMu.Lock()
result[id] = isrc
resultMu.Unlock()
}(trackID)
}
wg.Wait()
return result
}
@@ -668,7 +691,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
// Pre-allocate with expected capacity
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
// Add first batch of tracks
for _, item := range data.Tracks.Items {
if item.Track == nil {
@@ -695,7 +718,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
nextURL := data.Tracks.Next
for nextURL != "" {
var pageData struct {
Items []struct {
@@ -755,10 +778,10 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
// Fetch artist info
var artistData struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
Followers struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
Followers struct {
Total int `json:"total"`
} `json:"followers"`
Popularity int `json:"popularity"`
@@ -941,15 +964,15 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
defer c.rngMu.Unlock()
// Use Mac User-Agent format (same as PC version)
macMajor := c.rng.Intn(4) + 11 // 11-14
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
macMajor := c.rng.Intn(4) + 11 // 11-14
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
chromePatch := c.rng.Intn(65) + 60 // 60-124
safariMajor := c.rng.Intn(7) + 530 // 530-536
safariMinor := c.rng.Intn(6) + 30 // 30-35
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",
+229 -182
View File
@@ -1,10 +1,12 @@
package gobackend
import (
"context"
"bufio"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
@@ -345,27 +347,28 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
func normalizeTitle(title string) string {
normalized := strings.ToLower(strings.TrimSpace(title))
// Remove common suffixes in parentheses or brackets
suffixPatterns := []string{
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
" (bonus track)", " (single)", " (album version)", " (radio edit)",
" [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
}
for _, suffix := range suffixPatterns {
normalized = strings.TrimSuffix(normalized, suffix)
}
// Remove multiple spaces
for strings.Contains(normalized, " ") {
normalized = strings.ReplaceAll(normalized, " ", " ")
}
return normalized
}
// normalizeTitle normalizes a track title for comparison
// Kept for potential future use
// func normalizeTitle(title string) string {
// normalized := strings.ToLower(strings.TrimSpace(title))
//
// // Remove common suffixes in parentheses or brackets
// suffixPatterns := []string{
// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
// " (bonus track)", " (single)", " (album version)", " (radio edit)",
// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
// }
// for _, suffix := range suffixPatterns {
// normalized = strings.TrimSuffix(normalized, suffix)
// }
//
// // Remove multiple spaces
// for strings.Contains(normalized, " ") {
// normalized = strings.ReplaceAll(normalized, " ", " ")
// }
//
// return normalized
// }
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
@@ -648,6 +651,7 @@ type tidalAPIResult struct {
// getDownloadURLParallel requests download URL from all APIs in parallel
// Returns the first successful result (supports both v1 and v2 API formats)
// "Siapa cepat dia dapat" - first success wins
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
if len(apis) == 0 {
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
@@ -663,38 +667,33 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
go func(api string) {
reqStart := time.Now()
// Create client with longer timeout for parallel requests
// Create client with timeout for parallel requests
client := &http.Client{
Timeout: 15 * time.Second,
}
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
GoLog("[Tidal] [Parallel] Starting request to: %s\n", api)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
GoLog("[Tidal] [Parallel] %s - Failed to create request: %v\n", api, err)
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
resp, err := client.Do(req)
if err != nil {
GoLog("[Tidal] [Parallel] %s - Request failed: %v\n", api, err)
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
GoLog("[Tidal] [Parallel] %s - HTTP %d\n", api, resp.StatusCode)
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
GoLog("[Tidal] [Parallel] %s - Failed to read body: %v\n", api, err)
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
@@ -704,14 +703,10 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
if v2Response.Data.AssetPresentation == "PREVIEW" {
GoLog("[Tidal] [Parallel] %s - Rejecting PREVIEW response\n", api)
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
return
}
GoLog("[Tidal] [Parallel] %s - Got FULL track (v2): %d-bit/%dHz in %v\n",
api, v2Response.Data.BitDepth, v2Response.Data.SampleRate, time.Since(reqStart))
info := TidalDownloadInfo{
URL: "MANIFEST:" + v2Response.Data.Manifest,
BitDepth: v2Response.Data.BitDepth,
@@ -728,7 +723,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
GoLog("[Tidal] [Parallel] %s - Got direct URL (v1) in %v\n", api, time.Since(reqStart))
info := TidalDownloadInfo{
URL: item.OriginalTrackURL,
BitDepth: 16,
@@ -740,148 +734,51 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
}
}
GoLog("[Tidal] [Parallel] %s - No download URL in response\n", api)
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)}
}(apiURL)
}
// Collect results - return first success
var errors []string
successCount := 0
failCount := 0
for i := 0; i < len(apis); i++ {
result := <-resultChan
if result.err == nil {
successCount++
if successCount == 1 {
// First success - use this one
GoLog("[Tidal] [Parallel] ✓ Using response from %s (took %v, total %v)\n",
result.apiURL, result.duration, time.Since(startTime))
// First success - use this one
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
// Don't return immediately - let other goroutines finish to avoid leaks
// But we'll use this result
go func() {
// Drain remaining results
for j := i + 1; j < len(apis); j++ {
<-resultChan
}
}()
// Don't return immediately - drain remaining results to avoid goroutine leaks
go func(remaining int) {
for j := 0; j < remaining; j++ {
<-resultChan
}
}(len(apis) - i - 1)
return result.apiURL, result.info, nil
}
} else {
failCount++
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
GoLog("[Tidal] [Parallel] ✗ %s failed: %s (took %v)\n", result.apiURL, errMsg, result.duration)
GoLog("[Tidal] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
return result.apiURL, result.info, nil
}
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
}
GoLog("[Tidal] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
}
// getDownloadURLSequential requests download URL from APIs sequentially (fallback)
// Returns the first successful result (supports both v1 and v2 API formats)
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
if len(apis) == 0 {
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
}
client := NewHTTPClientWithTimeout(DefaultTimeout)
retryConfig := DefaultRetryConfig()
var errors []string
for _, apiURL := range apis {
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
GoLog("[Tidal] Trying API: %s\n", reqURL)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
resp, err := DoRequestWithRetry(client, req, retryConfig)
if err != nil {
GoLog("[Tidal] API error: %v\n", err)
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
body, err := ReadResponseBody(resp)
resp.Body.Close()
if err != nil {
GoLog("[Tidal] Read body error: %v\n", err)
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
continue
}
// Log response preview
bodyPreview := string(body)
if len(bodyPreview) > 300 {
bodyPreview = bodyPreview[:300] + "..."
}
GoLog("[Tidal] API response (HTTP %d): %s\n", resp.StatusCode, bodyPreview)
// Try v2 format first (object with manifest)
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
GoLog("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n",
apiURL, v2Response.Data.BitDepth, v2Response.Data.SampleRate, v2Response.Data.AssetPresentation)
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
if v2Response.Data.AssetPresentation == "PREVIEW" {
GoLog("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL)
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "returned PREVIEW instead of FULL"))
continue
}
GoLog("[Tidal] ✓ Got FULL track from %s\n", apiURL)
info := TidalDownloadInfo{
URL: "MANIFEST:" + v2Response.Data.Manifest,
BitDepth: v2Response.Data.BitDepth,
SampleRate: v2Response.Data.SampleRate,
}
return apiURL, info, nil
}
// Fallback to v1 format (array with OriginalTrackUrl)
var v1Responses []struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
// v1 format doesn't have quality info, assume 16-bit/44.1kHz
info := TidalDownloadInfo{
URL: item.OriginalTrackURL,
BitDepth: 16,
SampleRate: 44100,
}
return apiURL, info, nil
}
}
}
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response"))
}
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
}
// GetDownloadURL gets download URL for a track - tries APIs sequentially
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
apis := t.GetAvailableAPIs()
if len(apis) == 0 {
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
}
_, info, err := getDownloadURLSequential(apis, trackID, quality)
// Use parallel approach - request from all APIs simultaneously
_, info, err := getDownloadURLParallel(apis, trackID, quality)
if err != nil {
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
}
@@ -991,29 +888,45 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
// DownloadFile downloads a file from URL with progress tracking
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
// Handle manifest-based download (DASH/BTS)
if strings.HasPrefix(downloadURL, "MANIFEST:") {
// Initialize progress tracking for manifest downloads
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
}
// Initialize item progress for direct downloads
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(t.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err
}
defer resp.Body.Close()
@@ -1053,6 +966,9 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
@@ -1073,7 +989,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return nil
}
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error {
fmt.Println("[Tidal] Parsing manifest...")
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
if err != nil {
@@ -1092,7 +1008,11 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
// Note: Progress tracking is initialized by the caller (DownloadFile)
req, err := http.NewRequest("GET", directURL, nil)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", directURL, nil)
if err != nil {
GoLog("[Tidal] BTS request creation failed: %v\n", err)
return fmt.Errorf("failed to create request: %w", err)
@@ -1100,6 +1020,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
resp, err := client.Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] BTS download failed: %v\n", err)
return fmt.Errorf("failed to download file: %w", err)
}
@@ -1135,6 +1058,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if closeErr != nil {
@@ -1167,10 +1093,25 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
// Download initialization segment
GoLog("[Tidal] Downloading init segment...\n")
resp, err := client.Get(initURL)
if isDownloadCancelled(itemID) {
out.Close()
os.Remove(m4aPath)
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
if err != nil {
out.Close()
os.Remove(m4aPath)
GoLog("[Tidal] Init segment request failed: %v\n", err)
return fmt.Errorf("failed to create init segment request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
out.Close()
os.Remove(m4aPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] Init segment download failed: %v\n", err)
return fmt.Errorf("failed to download init segment: %w", err)
}
@@ -1186,6 +1127,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
if err != nil {
out.Close()
os.Remove(m4aPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] Init segment write failed: %v\n", err)
return fmt.Errorf("failed to write init segment: %w", err)
}
@@ -1193,6 +1137,12 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
// Download media segments with progress
totalSegments := len(mediaURLs)
for i, mediaURL := range mediaURLs {
if isDownloadCancelled(itemID) {
out.Close()
os.Remove(m4aPath)
return ErrDownloadCancelled
}
if i%10 == 0 || i == totalSegments-1 {
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
}
@@ -1203,10 +1153,20 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
SetItemProgress(itemID, progress, 0, 0)
}
resp, err := client.Get(mediaURL)
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
if err != nil {
out.Close()
os.Remove(m4aPath)
GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err)
return fmt.Errorf("failed to create segment %d request: %w", i+1, err)
}
resp, err := client.Do(req)
if err != nil {
out.Close()
os.Remove(m4aPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err)
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
}
@@ -1222,6 +1182,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
if err != nil {
out.Close()
os.Remove(m4aPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
}
@@ -1266,24 +1229,27 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return true
}
// Check first artist (before comma or feat)
spotifyFirst := strings.Split(normSpotify, ",")[0]
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
spotifyFirst = strings.TrimSpace(spotifyFirst)
// Split artists by common separators (comma, feat, ft., &, and)
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
spotifyArtists := splitArtists(normSpotify)
tidalArtists := splitArtists(normTidal)
tidalFirst := strings.Split(normTidal, ",")[0]
tidalFirst = strings.Split(tidalFirst, " feat")[0]
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
tidalFirst = strings.TrimSpace(tidalFirst)
if spotifyFirst == tidalFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
return true
// Check if ANY expected artist matches ANY found artist
for _, exp := range spotifyArtists {
for _, fnd := range tidalArtists {
if exp == fnd {
return true
}
// Also check contains for partial matches
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true
}
// Check same words different order
if sameWordsUnordered(exp, fnd) {
GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true
}
}
}
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
@@ -1299,6 +1265,67 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return false
}
// splitArtists splits artist string by common separators
func splitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|")
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
normalized = strings.ReplaceAll(normalized, " ft ", "|")
normalized = strings.ReplaceAll(normalized, " & ", "|")
normalized = strings.ReplaceAll(normalized, " and ", "|")
normalized = strings.ReplaceAll(normalized, ", ", "|")
normalized = strings.ReplaceAll(normalized, " x ", "|")
parts := strings.Split(normalized, "|")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// sameWordsUnordered checks if two strings have the same words regardless of order
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
func sameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a)
wordsB := strings.Fields(b)
// 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] {
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
}
if sortedB[i] > sortedB[j] {
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
}
}
}
for i := range sortedA {
if sortedA[i] != sortedB[i] {
return false
}
}
return true
}
// titlesMatch checks if track titles are similar enough
func titlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
@@ -1473,14 +1500,15 @@ func isLatinScript(s string) bool {
}
// isASCIIString checks if a string contains only ASCII characters
func isASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// Kept for potential future use
// func isASCIIString(s string) bool {
// for _, r := range s {
// if r > 127 {
// return false
// }
// }
// return true
// }
// downloadFromTidal downloads a track using the request parameters
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
@@ -1497,8 +1525,24 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
var track *TidalTrack
var err error
// STRATEGY 0: Use pre-fetched Tidal ID from Odesli enrichment (highest priority)
if req.TidalID != "" {
GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID)
// Parse track ID (could be a number or extracted from URL)
var trackID int64
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
track, err = downloader.GetTrackInfoByID(trackID)
if err != nil {
GoLog("[Tidal] Failed to get track by Odesli ID %d: %v\n", trackID, err)
track = nil
} else if track != nil {
GoLog("[Tidal] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Artist.Name)
}
}
}
// OPTIMIZATION: Check cache first for track ID
if req.ISRC != "" {
if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
@@ -1532,7 +1576,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
}
// Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate)
// Strategy 2: Try SongLink if we have Spotify ID
if track == nil && req.SpotifyID != "" {
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
var tidalURL string
@@ -1710,6 +1754,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}())
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return TidalDownloadResult{}, ErrDownloadCancelled
}
GoLog("[Tidal] Download failed with error: %v\n", err)
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
+307
View File
@@ -120,6 +120,12 @@ import Gobackend // Import Go framework
let itemId = args["item_id"] as! String
GobackendClearItemProgress(itemId)
return nil
case "cancelDownload":
let args = call.arguments as! [String: Any]
let itemId = args["item_id"] as! String
GobackendCancelDownload(itemId)
return nil
case "setDownloadDirectory":
let args = call.arguments as! [String: Any]
@@ -256,6 +262,10 @@ import Gobackend // Import Go framework
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
return nil
case "hasSpotifyCredentials":
let hasCredentials = GobackendCheckSpotifyCredentials()
return hasCredentials
// Log methods
case "getLogs":
let response = GobackendGetLogs()
@@ -281,6 +291,303 @@ import Gobackend // Import Go framework
GobackendSetLoggingEnabled(enabled)
return nil
// Extension System methods
case "initExtensionSystem":
let args = call.arguments as! [String: Any]
let extensionsDir = args["extensions_dir"] as! String
let dataDir = args["data_dir"] as! String
GobackendInitExtensionSystem(extensionsDir, dataDir, &error)
if let error = error { throw error }
return nil
case "loadExtensionsFromDir":
let args = call.arguments as! [String: Any]
let dirPath = args["dir_path"] as! String
let response = GobackendLoadExtensionsFromDir(dirPath, &error)
if let error = error { throw error }
return response
case "loadExtensionFromPath":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendLoadExtensionFromPath(filePath, &error)
if let error = error { throw error }
return response
case "unloadExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
GobackendUnloadExtensionByID(extensionId, &error)
if let error = error { throw error }
return nil
case "getInstalledExtensions":
let response = GobackendGetInstalledExtensions(&error)
if let error = error { throw error }
return response
case "setExtensionEnabled":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let enabled = args["enabled"] as? Bool ?? false
GobackendSetExtensionEnabledByID(extensionId, enabled, &error)
if let error = error { throw error }
return nil
case "setProviderPriority":
let args = call.arguments as! [String: Any]
let priorityJson = args["priority"] as! String
GobackendSetProviderPriorityJSON(priorityJson, &error)
if let error = error { throw error }
return nil
case "getProviderPriority":
let response = GobackendGetProviderPriorityJSON(&error)
if let error = error { throw error }
return response
case "setMetadataProviderPriority":
let args = call.arguments as! [String: Any]
let priorityJson = args["priority"] as! String
GobackendSetMetadataProviderPriorityJSON(priorityJson, &error)
if let error = error { throw error }
return nil
case "getMetadataProviderPriority":
let response = GobackendGetMetadataProviderPriorityJSON(&error)
if let error = error { throw error }
return response
case "getExtensionSettings":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendGetExtensionSettingsJSON(extensionId, &error)
if let error = error { throw error }
return response
case "setExtensionSettings":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let settingsJson = args["settings"] as! String
GobackendSetExtensionSettingsJSON(extensionId, settingsJson, &error)
if let error = error { throw error }
return nil
case "searchTracksWithExtensions":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let limit = args["limit"] as? Int ?? 20
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
if let error = error { throw error }
return response
case "downloadWithExtensions":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
if let error = error { throw error }
return response
case "removeExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
GobackendRemoveExtensionByID(extensionId, &error)
if let error = error { throw error }
return nil
case "upgradeExtension":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendUpgradeExtensionFromPath(filePath, &error)
if let error = error { throw error }
return response
case "checkExtensionUpgrade":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendCheckExtensionUpgradeFromPath(filePath, &error)
if let error = error { throw error }
return response
case "cleanupExtensions":
GobackendCleanupExtensions()
return nil
// Extension Auth API
case "getExtensionPendingAuth":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendGetExtensionPendingAuthJSON(extensionId, &error)
if let error = error { throw error }
return response
case "setExtensionAuthCode":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let authCode = args["auth_code"] as! String
GobackendSetExtensionAuthCodeByID(extensionId, authCode)
return nil
case "setExtensionTokens":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let accessToken = args["access_token"] as! String
let refreshToken = args["refresh_token"] as? String ?? ""
let expiresIn = args["expires_in"] as? Int ?? 0
GobackendSetExtensionTokensByID(extensionId, accessToken, refreshToken, Int(expiresIn))
return nil
case "clearExtensionPendingAuth":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
GobackendClearExtensionPendingAuthByID(extensionId)
return nil
case "isExtensionAuthenticated":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendIsExtensionAuthenticatedByID(extensionId)
return response
case "getAllPendingAuthRequests":
let response = GobackendGetAllPendingAuthRequestsJSON(&error)
if let error = error { throw error }
return response
// Extension FFmpeg API
case "getPendingFFmpegCommand":
let args = call.arguments as! [String: Any]
let commandId = args["command_id"] as! String
let response = GobackendGetPendingFFmpegCommandJSON(commandId, &error)
if let error = error { throw error }
return response
case "setFFmpegCommandResult":
let args = call.arguments as! [String: Any]
let commandId = args["command_id"] as! String
let success = args["success"] as? Bool ?? false
let output = args["output"] as? String ?? ""
let errorMsg = args["error"] as? String ?? ""
GobackendSetFFmpegCommandResult(commandId, success, output, errorMsg)
return nil
case "getAllPendingFFmpegCommands":
let response = GobackendGetAllPendingFFmpegCommandsJSON(&error)
if let error = error { throw error }
return response
// Extension Custom Search API
case "customSearchWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let query = args["query"] as! String
let optionsJson = args["options"] as? String ?? ""
let response = GobackendCustomSearchWithExtensionJSON(extensionId, query, optionsJson, &error)
if let error = error { throw error }
return response
case "getSearchProviders":
let response = GobackendGetSearchProvidersJSON(&error)
if let error = error { throw error }
return response
// Extension URL Handler API
case "handleURLWithExtension":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendHandleURLWithExtensionJSON(url, &error)
if let error = error { throw error }
return response
case "findURLHandler":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendFindURLHandlerJSON(url)
return response
case "getURLHandlers":
let response = GobackendGetURLHandlersJSON(&error)
if let error = error { throw error }
return response
case "getAlbumWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let albumId = args["album_id"] as! String
let response = GobackendGetAlbumWithExtensionJSON(extensionId, albumId, &error)
if let error = error { throw error }
return response
case "getPlaylistWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let playlistId = args["playlist_id"] as! String
let response = GobackendGetPlaylistWithExtensionJSON(extensionId, playlistId, &error)
if let error = error { throw error }
return response
case "getArtistWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let artistId = args["artist_id"] as! String
let response = GobackendGetArtistWithExtensionJSON(extensionId, artistId, &error)
if let error = error { throw error }
return response
// Extension Post-Processing API
case "runPostProcessing":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let metadataJson = args["metadata"] as? String ?? ""
let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error)
if let error = error { throw error }
return response
case "getPostProcessingProviders":
let response = GobackendGetPostProcessingProvidersJSON(&error)
if let error = error { throw error }
return response
// Extension Store
case "initExtensionStore":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
GobackendInitExtensionStoreJSON(cacheDir, &error)
if let error = error { throw error }
return nil
case "getStoreExtensions":
let args = call.arguments as! [String: Any]
let forceRefresh = args["force_refresh"] as? Bool ?? false
let response = GobackendGetStoreExtensionsJSON(forceRefresh, &error)
if let error = error { throw error }
return response
case "searchStoreExtensions":
let args = call.arguments as! [String: Any]
let query = args["query"] as? String ?? ""
let category = args["category"] as? String ?? ""
let response = GobackendSearchStoreExtensionsJSON(query, category, &error)
if let error = error { throw error }
return response
case "getStoreCategories":
let response = GobackendGetStoreCategoriesJSON(&error)
if let error = error { throw error }
return response
case "downloadStoreExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let destDir = args["dest_dir"] as! String
let response = GobackendDownloadStoreExtensionJSON(extensionId, destDir, &error)
if let error = error { throw error }
return response
case "clearStoreCache":
GobackendClearStoreCacheJSON(&error)
if let error = error { throw error }
return nil
default:
throw NSError(
domain: "SpotiFLAC",
+17
View File
@@ -4,6 +4,23 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>de</string>
<string>es</string>
<string>fr</string>
<string>hi</string>
<string>id</string>
<string>ja</string>
<string>ko</string>
<string>nl</string>
<string>pt</string>
<string>ru</string>
<string>zh</string>
<string>zh-Hans</string>
<string>zh-Hant</string>
</array>
<key>CFBundleDisplayName</key>
<string>SpotiFLAC</string>
<key>CFBundleExecutable</key>
+6
View File
@@ -0,0 +1,6 @@
arb-dir: lib/l10n/arb
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
output-dir: lib/l10n
nullable-getter: false
+18
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:spotiflac_android/screens/main_shell.dart';
import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
final _routerProvider = Provider<GoRouter>((ref) {
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
@@ -31,6 +33,13 @@ class SpotiFLACApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(_routerProvider);
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
// Convert locale string to Locale object
Locale? locale;
if (localeString != 'system') {
locale = Locale(localeString);
}
return DynamicColorWrapper(
builder: (lightTheme, darkTheme, themeMode) {
@@ -43,6 +52,15 @@ class SpotiFLACApp extends ConsumerWidget {
themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut,
routerConfig: router,
// Localization
locale: locale, // null = follow system
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
);
},
);
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '2.2.8';
static const String buildNumber = '50';
static const String version = '3.1.0';
static const String buildNumber = '59';
static const String fullVersion = '$version+$buildNumber';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+681
View File
@@ -0,0 +1,681 @@
{
"@@locale": "id",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
"navHome": "Beranda",
"navHistory": "Riwayat",
"navSettings": "Pengaturan",
"navStore": "Toko",
"homeTitle": "Beranda",
"homeSearchHint": "Tempel URL Spotify atau cari...",
"homeSearchHintExtension": "Cari dengan {extensionName}...",
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama",
"homeSupports": "Mendukung: URL Track, Album, Playlist, Artis",
"homeRecent": "Terbaru",
"historyTitle": "Riwayat",
"historyDownloading": "Mengunduh ({count})",
"historyDownloaded": "Terunduh",
"historyFilterAll": "Semua",
"historyFilterAlbums": "Album",
"historyFilterSingles": "Single",
"historyTracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"historyAlbumsCount": "{count, plural, =1{1 album} other{{count} album}}",
"historyNoDownloads": "Tidak ada riwayat unduhan",
"historyNoDownloadsSubtitle": "Lagu yang diunduh akan muncul di sini",
"historyNoAlbums": "Tidak ada unduhan album",
"historyNoAlbumsSubtitle": "Unduh beberapa lagu dari album untuk melihatnya di sini",
"historyNoSingles": "Tidak ada unduhan single",
"historyNoSinglesSubtitle": "Unduhan lagu satuan akan muncul di sini",
"settingsTitle": "Pengaturan",
"settingsDownload": "Unduhan",
"settingsAppearance": "Tampilan",
"settingsOptions": "Opsi",
"settingsExtensions": "Ekstensi",
"settingsAbout": "Tentang",
"downloadTitle": "Unduhan",
"downloadLocation": "Lokasi Unduhan",
"downloadLocationSubtitle": "Pilih tempat menyimpan file",
"downloadLocationDefault": "Lokasi default",
"downloadDefaultService": "Layanan Default",
"downloadDefaultServiceSubtitle": "Layanan yang digunakan untuk unduhan",
"downloadDefaultQuality": "Kualitas Default",
"downloadAskQuality": "Tanya Kualitas Sebelum Unduh",
"downloadAskQualitySubtitle": "Tampilkan pemilih kualitas untuk setiap unduhan",
"downloadFilenameFormat": "Format Nama File",
"downloadFolderOrganization": "Organisasi Folder",
"downloadSeparateSingles": "Pisahkan Single",
"downloadSeparateSinglesSubtitle": "Letakkan lagu satuan di folder terpisah",
"qualityBest": "Terbaik",
"qualityFlac": "FLAC",
"quality320": "320 kbps",
"quality128": "128 kbps",
"appearanceTitle": "Tampilan",
"appearanceTheme": "Tema",
"appearanceThemeSystem": "Sistem",
"appearanceThemeLight": "Terang",
"appearanceThemeDark": "Gelap",
"appearanceDynamicColor": "Warna Dinamis",
"appearanceDynamicColorSubtitle": "Gunakan warna dari wallpaper Anda",
"appearanceAccentColor": "Warna Aksen",
"appearanceHistoryView": "Tampilan Riwayat",
"appearanceHistoryViewList": "Daftar",
"appearanceHistoryViewGrid": "Grid",
"optionsTitle": "Opsi",
"optionsSearchSource": "Sumber Pencarian",
"optionsPrimaryProvider": "Provider Utama",
"optionsPrimaryProviderSubtitle": "Layanan yang digunakan saat mencari berdasarkan nama lagu.",
"optionsUsingExtension": "Menggunakan ekstensi: {extensionName}",
"optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi",
"optionsAutoFallback": "Auto Fallback",
"optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal",
"optionsUseExtensionProviders": "Gunakan Provider Ekstensi",
"optionsUseExtensionProvidersOn": "Ekstensi akan dicoba terlebih dahulu",
"optionsUseExtensionProvidersOff": "Hanya menggunakan provider bawaan",
"optionsEmbedLyrics": "Sematkan Lirik",
"optionsEmbedLyricsSubtitle": "Sematkan lirik sinkron ke file FLAC",
"optionsMaxQualityCover": "Cover Kualitas Maksimal",
"optionsMaxQualityCoverSubtitle": "Unduh cover art resolusi tertinggi",
"optionsConcurrentDownloads": "Unduhan Bersamaan",
"optionsConcurrentSequential": "Berurutan (1 per waktu)",
"optionsConcurrentParallel": "{count} unduhan paralel",
"optionsConcurrentWarning": "Unduhan paralel dapat memicu pembatasan rate",
"optionsExtensionStore": "Toko Ekstensi",
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi",
"optionsCheckUpdates": "Periksa Pembaruan",
"optionsCheckUpdatesSubtitle": "Beritahu saat versi baru tersedia",
"optionsUpdateChannel": "Saluran Pembaruan",
"optionsUpdateChannelStable": "Hanya rilis stabil",
"optionsUpdateChannelPreview": "Dapatkan rilis preview",
"optionsUpdateChannelWarning": "Preview mungkin mengandung bug atau fitur belum lengkap",
"optionsClearHistory": "Hapus Riwayat Unduhan",
"optionsClearHistorySubtitle": "Hapus semua lagu dari riwayat",
"optionsDetailedLogging": "Log Detail",
"optionsDetailedLoggingOn": "Log detail sedang direkam",
"optionsDetailedLoggingOff": "Aktifkan untuk laporan bug",
"optionsSpotifyCredentials": "Kredensial Spotify",
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsRequired": "Diperlukan - ketuk untuk mengatur",
"optionsSpotifyWarning": "Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com",
"extensionsTitle": "Ekstensi",
"extensionsInstalled": "Ekstensi Terpasang",
"extensionsNone": "Tidak ada ekstensi terpasang",
"extensionsNoneSubtitle": "Pasang ekstensi dari tab Toko",
"extensionsEnabled": "Aktif",
"extensionsDisabled": "Nonaktif",
"extensionsVersion": "Versi {version}",
"extensionsAuthor": "oleh {author}",
"extensionsUninstall": "Copot",
"extensionsSetAsSearch": "Jadikan Provider Pencarian",
"storeTitle": "Toko Ekstensi",
"storeSearch": "Cari ekstensi...",
"storeInstall": "Pasang",
"storeInstalled": "Terpasang",
"storeUpdate": "Perbarui",
"aboutTitle": "Tentang",
"aboutContributors": "Kontributor",
"aboutMobileDeveloper": "Pengembang versi mobile",
"aboutOriginalCreator": "Pencipta SpotiFLAC asli",
"aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kami yang indah!",
"aboutSpecialThanks": "Terima Kasih Khusus",
"aboutLinks": "Tautan",
"aboutMobileSource": "Kode sumber mobile",
"aboutPCSource": "Kode sumber PC",
"aboutReportIssue": "Laporkan masalah",
"aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui",
"aboutFeatureRequest": "Permintaan fitur",
"aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi",
"aboutSupport": "Dukungan",
"aboutBuyMeCoffee": "Traktir saya kopi",
"aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi",
"aboutApp": "Aplikasi",
"aboutVersion": "Versi",
"albumTitle": "Album",
"albumTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"albumDownloadAll": "Unduh Semua",
"albumDownloadRemaining": "Unduh Sisanya",
"playlistTitle": "Playlist",
"artistTitle": "Artis",
"artistAlbums": "Album",
"artistSingles": "Single & EP",
"trackMetadataTitle": "Info Lagu",
"trackMetadataArtist": "Artis",
"trackMetadataAlbum": "Album",
"trackMetadataDuration": "Durasi",
"trackMetadataQuality": "Kualitas",
"trackMetadataPath": "Lokasi File",
"trackMetadataDownloadedAt": "Diunduh",
"trackMetadataService": "Layanan",
"trackMetadataPlay": "Putar",
"trackMetadataShare": "Bagikan",
"trackMetadataDelete": "Hapus",
"trackMetadataRedownload": "Unduh ulang",
"trackMetadataOpenFolder": "Buka Folder",
"setupTitle": "Selamat Datang di SpotiFLAC",
"setupSubtitle": "Mari mulai pengaturan",
"setupStoragePermission": "Izin Penyimpanan",
"setupStoragePermissionSubtitle": "Diperlukan untuk menyimpan file unduhan",
"setupStoragePermissionGranted": "Izin diberikan",
"setupStoragePermissionDenied": "Izin ditolak",
"setupGrantPermission": "Berikan Izin",
"setupDownloadLocation": "Lokasi Unduhan",
"setupChooseFolder": "Pilih Folder",
"setupContinue": "Lanjutkan",
"setupSkip": "Lewati untuk sekarang",
"dialogCancel": "Batal",
"dialogOk": "OK",
"dialogSave": "Simpan",
"dialogDelete": "Hapus",
"dialogRetry": "Coba Lagi",
"dialogClose": "Tutup",
"dialogYes": "Ya",
"dialogNo": "Tidak",
"dialogClear": "Hapus",
"dialogConfirm": "Konfirmasi",
"dialogDone": "Selesai",
"dialogClearHistoryTitle": "Hapus Riwayat",
"dialogClearHistoryMessage": "Apakah Anda yakin ingin menghapus semua riwayat unduhan? Ini tidak dapat dibatalkan.",
"dialogDeleteSelectedTitle": "Hapus yang Dipilih",
"dialogDeleteSelectedMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari riwayat?\n\nIni juga akan menghapus file dari penyimpanan.",
"dialogImportPlaylistTitle": "Impor Playlist",
"dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?",
"snackbarAddedToQueue": "Menambahkan \"{trackName}\" ke antrian",
"snackbarAddedTracksToQueue": "Menambahkan {count} lagu ke antrian",
"snackbarAlreadyDownloaded": "\"{trackName}\" sudah diunduh",
"snackbarHistoryCleared": "Riwayat dihapus",
"snackbarCredentialsSaved": "Kredensial disimpan",
"snackbarCredentialsCleared": "Kredensial dihapus",
"snackbarDeletedTracks": "Menghapus {count} {count, plural, =1{lagu} other{lagu}}",
"snackbarCannotOpenFile": "Tidak dapat membuka file: {error}",
"snackbarFillAllFields": "Harap isi semua field",
"snackbarViewQueue": "Lihat Antrian",
"errorRateLimited": "Dibatasi",
"errorRateLimitedMessage": "Terlalu banyak permintaan. Harap tunggu sebentar sebelum mencari lagi.",
"errorFailedToLoad": "Gagal memuat {item}",
"errorNoTracksFound": "Tidak ada lagu ditemukan",
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
"statusQueued": "Mengantri",
"statusDownloading": "Mengunduh",
"statusFinalizing": "Menyelesaikan",
"statusCompleted": "Selesai",
"statusFailed": "Gagal",
"statusSkipped": "Dilewati",
"statusPaused": "Dijeda",
"actionPause": "Jeda",
"actionResume": "Lanjutkan",
"actionCancel": "Batal",
"actionStop": "Hentikan",
"actionSelect": "Pilih",
"actionSelectAll": "Pilih Semua",
"actionDeselect": "Batal Pilih",
"actionPaste": "Tempel",
"actionImportCsv": "Impor CSV",
"actionRemoveCredentials": "Hapus Kredensial",
"actionSaveCredentials": "Simpan Kredensial",
"selectionSelected": "{count} dipilih",
"selectionAllSelected": "Semua lagu dipilih",
"selectionTapToSelect": "Ketuk lagu untuk memilih",
"selectionDeleteTracks": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
"selectionSelectToDelete": "Pilih lagu untuk dihapus",
"progressFetchingMetadata": "Mengambil metadata... {current}/{total}",
"progressReadingCsv": "Membaca CSV...",
"searchSongs": "Lagu",
"searchArtists": "Artis",
"searchAlbums": "Album",
"searchPlaylists": "Playlist",
"tooltipPlay": "Putar",
"tooltipCancel": "Batal",
"tooltipStop": "Hentikan",
"tooltipRetry": "Coba Lagi",
"tooltipRemove": "Hapus",
"tooltipClear": "Hapus",
"tooltipPaste": "Tempel",
"filenameFormat": "Format Nama File",
"filenameFormatPreview": "Pratinjau: {preview}",
"folderOrganization": "Organisasi Folder",
"folderOrganizationNone": "Tanpa organisasi",
"folderOrganizationByArtist": "Berdasarkan Artis",
"folderOrganizationByAlbum": "Berdasarkan Album",
"folderOrganizationByArtistAlbum": "Artis/Album",
"updateAvailable": "Pembaruan Tersedia",
"updateNewVersion": "Versi {version} tersedia",
"updateDownload": "Unduh",
"updateLater": "Nanti",
"updateChangelog": "Log Perubahan",
"providerPriority": "Prioritas Provider",
"providerPrioritySubtitle": "Seret untuk mengatur ulang provider unduhan",
"metadataProviderPriority": "Prioritas Provider Metadata",
"metadataProviderPrioritySubtitle": "Urutan yang digunakan saat mengambil metadata lagu",
"logTitle": "Log",
"logCopy": "Salin Log",
"logClear": "Hapus Log",
"logShare": "Bagikan Log",
"logEmpty": "Belum ada log",
"logCopied": "Log disalin ke clipboard",
"credentialsTitle": "Kredensial Spotify",
"credentialsDescription": "Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.",
"credentialsClientId": "Client ID",
"credentialsClientIdHint": "Tempel Client ID",
"credentialsClientSecret": "Client Secret",
"credentialsClientSecretHint": "Tempel Client Secret",
"channelStable": "Stabil",
"channelPreview": "Preview",
"sectionSearchSource": "Sumber Pencarian",
"sectionDownload": "Unduhan",
"sectionPerformance": "Performa",
"sectionApp": "Aplikasi",
"sectionData": "Data",
"sectionDebug": "Debug",
"sectionService": "Layanan",
"sectionAudioQuality": "Kualitas Audio",
"sectionFileSettings": "Pengaturan File",
"sectionColor": "Warna",
"sectionTheme": "Tema",
"sectionLayout": "Tata Letak",
"sectionLanguage": "Bahasa",
"appearanceLanguage": "Bahasa Aplikasi",
"appearanceLanguageSubtitle": "Pilih bahasa yang kamu inginkan",
"languageSystem": "Bawaan Sistem",
"languageEnglish": "English",
"languageIndonesian": "Bahasa Indonesia",
"settingsAppearanceSubtitle": "Tema, warna, tampilan",
"settingsDownloadSubtitle": "Layanan, kualitas, format nama file",
"settingsOptionsSubtitle": "Fallback, lirik, cover art, pembaruan",
"settingsExtensionsSubtitle": "Kelola provider unduhan",
"settingsLogsSubtitle": "Lihat log aplikasi untuk debugging",
"loadingSharedLink": "Memuat link yang dibagikan...",
"pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar",
"artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}",
"artistCompilations": "Kompilasi",
"artistPopular": "Populer",
"artistMonthlyListeners": "{count} pendengar bulanan",
"tracksHeader": "Lagu",
"downloadAllCount": "Unduh Semua ({count})",
"tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"setupStorageAccessRequired": "Akses Penyimpanan Diperlukan",
"setupStorageAccessMessage": "SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.",
"setupStorageAccessMessageAndroid11": "Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.",
"setupOpenSettings": "Buka Pengaturan",
"setupPermissionDeniedMessage": "Izin ditolak. Harap berikan semua izin untuk melanjutkan.",
"setupPermissionRequired": "Izin {permissionType} Diperlukan",
"setupPermissionRequiredMessage": "Izin {permissionType} diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.",
"setupSelectDownloadFolder": "Pilih Folder Unduhan",
"setupUseDefaultFolder": "Gunakan Folder Default?",
"setupNoFolderSelected": "Tidak ada folder dipilih. Apakah Anda ingin menggunakan folder Musik default?",
"setupUseDefault": "Gunakan Default",
"setupDownloadLocationTitle": "Lokasi Unduhan",
"setupDownloadLocationIosMessage": "Di iOS, unduhan disimpan ke folder Documents aplikasi. Anda dapat mengaksesnya melalui aplikasi Files.",
"setupAppDocumentsFolder": "Folder Documents Aplikasi",
"setupAppDocumentsFolderSubtitle": "Direkomendasikan - dapat diakses via aplikasi Files",
"setupChooseFromFiles": "Pilih dari Files",
"setupChooseFromFilesSubtitle": "Pilih lokasi iCloud atau lainnya",
"setupIosEmptyFolderWarning": "Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.",
"setupDownloadInFlac": "Unduh lagu Spotify dalam format FLAC",
"setupStepStorage": "Penyimpanan",
"setupStepNotification": "Notifikasi",
"setupStepFolder": "Folder",
"setupStepSpotify": "Spotify",
"setupStepPermission": "Izin",
"setupStorageGranted": "Izin Penyimpanan Diberikan!",
"setupStorageRequired": "Izin Penyimpanan Diperlukan",
"setupStorageDescription": "SpotiFLAC membutuhkan izin penyimpanan untuk menyimpan file musik yang diunduh.",
"setupNotificationGranted": "Izin Notifikasi Diberikan!",
"setupNotificationEnable": "Aktifkan Notifikasi",
"setupNotificationDescription": "Dapatkan pemberitahuan saat unduhan selesai atau membutuhkan perhatian.",
"setupFolderSelected": "Folder Unduhan Dipilih!",
"setupFolderChoose": "Pilih Folder Unduhan",
"setupFolderDescription": "Pilih folder tempat musik yang diunduh akan disimpan.",
"setupChangeFolder": "Ubah Folder",
"setupSelectFolder": "Pilih Folder",
"setupSpotifyApiOptional": "Spotify API (Opsional)",
"setupSpotifyApiDescription": "Tambahkan kredensial Spotify API untuk hasil pencarian lebih baik dan akses ke konten eksklusif Spotify.",
"setupUseSpotifyApi": "Gunakan Spotify API",
"setupEnterCredentialsBelow": "Masukkan kredensial Anda di bawah",
"setupUsingDeezer": "Menggunakan Deezer (tidak perlu akun)",
"setupEnterClientId": "Masukkan Spotify Client ID",
"setupEnterClientSecret": "Masukkan Spotify Client Secret",
"setupGetFreeCredentials": "Dapatkan kredensial API gratis dari Spotify Developer Dashboard.",
"setupEnableNotifications": "Aktifkan Notifikasi",
"dialogImport": "Impor",
"dialogDiscard": "Buang",
"dialogRemove": "Hapus",
"dialogUninstall": "Copot",
"dialogDiscardChanges": "Buang Perubahan?",
"dialogUnsavedChanges": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?",
"dialogDownloadFailed": "Unduhan Gagal",
"dialogTrackLabel": "Lagu:",
"dialogArtistLabel": "Artis:",
"dialogErrorLabel": "Error:",
"dialogClearAll": "Hapus Semua",
"dialogClearAllDownloads": "Apakah Anda yakin ingin menghapus semua unduhan?",
"dialogRemoveFromDevice": "Hapus dari perangkat?",
"dialogRemoveExtension": "Hapus Ekstensi",
"dialogRemoveExtensionMessage": "Apakah Anda yakin ingin menghapus ekstensi ini? Tindakan ini tidak dapat dibatalkan.",
"dialogUninstallExtension": "Copot Ekstensi?",
"dialogUninstallExtensionMessage": "Apakah Anda yakin ingin menghapus {extensionName}?",
"snackbarFailedToLoad": "Gagal memuat: {error}",
"snackbarUrlCopied": "URL {platform} disalin ke clipboard",
"snackbarFileNotFound": "File tidak ditemukan",
"snackbarSelectExtFile": "Harap pilih file .spotiflac-ext",
"snackbarProviderPrioritySaved": "Prioritas provider disimpan",
"snackbarMetadataProviderSaved": "Prioritas provider metadata disimpan",
"snackbarExtensionInstalled": "{extensionName} terpasang.",
"snackbarExtensionUpdated": "{extensionName} diperbarui.",
"snackbarFailedToInstall": "Gagal memasang ekstensi",
"snackbarFailedToUpdate": "Gagal memperbarui ekstensi",
"storeFilterAll": "Semua",
"storeFilterMetadata": "Metadata",
"storeFilterDownload": "Unduhan",
"storeFilterUtility": "Utilitas",
"storeFilterLyrics": "Lirik",
"storeFilterIntegration": "Integrasi",
"storeClearFilters": "Hapus filter",
"storeNoResults": "Tidak ada ekstensi ditemukan",
"extensionProviderPriority": "Prioritas Provider",
"extensionInstallButton": "Pasang Ekstensi",
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProviderSubtitle": "Gunakan pencarian bawaan",
"extensionAuthor": "Pembuat",
"extensionId": "ID",
"extensionError": "Error",
"extensionCapabilities": "Kemampuan",
"extensionMetadataProvider": "Provider Metadata",
"extensionDownloadProvider": "Provider Unduhan",
"extensionLyricsProvider": "Provider Lirik",
"extensionUrlHandler": "Penanganan URL",
"extensionQualityOptions": "Opsi Kualitas",
"extensionPostProcessingHooks": "Hook Pasca-Pemrosesan",
"extensionPermissions": "Izin",
"extensionSettings": "Pengaturan",
"extensionRemoveButton": "Hapus Ekstensi",
"extensionUpdated": "Diperbarui",
"extensionMinAppVersion": "Versi App Minimum",
"qualityFlacLossless": "FLAC Lossless",
"qualityFlacLosslessSubtitle": "16-bit / 44.1kHz",
"qualityHiResFlac": "Hi-Res FLAC",
"qualityHiResFlacSubtitle": "24-bit / hingga 96kHz",
"qualityHiResFlacMax": "Hi-Res FLAC Max",
"qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz",
"qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan",
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
"downloadDirectory": "Direktori Unduhan",
"downloadSeparateSinglesFolder": "Folder Singles Terpisah",
"downloadAlbumFolderStructure": "Struktur Folder Album",
"downloadSaveFormat": "Simpan Format",
"downloadSelectService": "Pilih Layanan",
"downloadSelectQuality": "Pilih Kualitas",
"downloadFrom": "Unduh Dari",
"downloadDefaultQualityLabel": "Kualitas Default",
"downloadBestAvailable": "Terbaik tersedia",
"folderNone": "Tidak ada",
"folderNoneSubtitle": "Simpan semua file langsung ke folder unduhan",
"folderArtist": "Artis",
"folderArtistSubtitle": "Nama Artis/namafile",
"folderAlbum": "Album",
"folderAlbumSubtitle": "Nama Album/namafile",
"folderArtistAlbum": "Artis/Album",
"folderArtistAlbumSubtitle": "Nama Artis/Nama Album/namafile",
"serviceTidal": "Tidal",
"serviceQobuz": "Qobuz",
"serviceAmazon": "Amazon",
"serviceDeezer": "Deezer",
"serviceSpotify": "Spotify",
"logSearchHint": "Cari log...",
"logFilterLevel": "Level",
"logFilterSection": "Filter",
"logShareLogs": "Bagikan log",
"logClearLogs": "Hapus log",
"logClearLogsTitle": "Hapus Log",
"logClearLogsMessage": "Apakah Anda yakin ingin menghapus semua log?",
"logIspBlocking": "PEMBLOKIRAN ISP TERDETEKSI",
"logRateLimited": "DIBATASI",
"logNetworkError": "ERROR JARINGAN",
"logTrackNotFound": "LAGU TIDAK DITEMUKAN",
"appearanceAmoledDark": "AMOLED Gelap",
"appearanceAmoledDarkSubtitle": "Latar belakang hitam murni",
"appearanceChooseAccentColor": "Pilih Warna Aksen",
"appearanceChooseTheme": "Mode Tema",
"updateStartingDownload": "Memulai unduhan...",
"updateDownloadFailed": "Unduhan gagal",
"updateFailedMessage": "Gagal mengunduh pembaruan",
"updateNewVersionReady": "Versi baru sudah siap",
"updateCurrent": "Saat ini",
"updateNew": "Baru",
"updateDownloading": "Mengunduh...",
"updateWhatsNew": "Yang Baru",
"updateDownloadInstall": "Unduh & Pasang",
"updateDontRemind": "Jangan ingatkan",
"trackCopyFilePath": "Salin lokasi file",
"trackRemoveFromDevice": "Hapus dari perangkat",
"trackLoadLyrics": "Muat Lirik",
"dateToday": "Hari ini",
"dateYesterday": "Kemarin",
"dateDaysAgo": "{count} hari lalu",
"dateWeeksAgo": "{count} minggu lalu",
"dateMonthsAgo": "{count} bulan lalu",
"concurrentSequential": "Berurutan",
"concurrentParallel2": "2 Paralel",
"concurrentParallel3": "3 Paralel",
"filenameAvailablePlaceholders": "Placeholder yang tersedia:",
"filenameHint": "{artist} - {title}",
"tapToSeeError": "Ketuk untuk melihat detail error",
"setupProceedToNextStep": "Anda dapat melanjutkan ke langkah berikutnya.",
"setupNotificationProgressDescription": "Anda akan menerima notifikasi progres unduhan.",
"setupNotificationBackgroundDescription": "Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.",
"setupSkipForNow": "Lewati untuk sekarang",
"setupBack": "Kembali",
"setupNext": "Lanjut",
"setupGetStarted": "Mulai",
"setupSkipAndStart": "Lewati & Mulai",
"setupAllowAccessToManageFiles": "Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.",
"setupGetCredentialsFromSpotify": "Dapatkan kredensial dari developer.spotify.com",
"trackMetadata": "Metadata",
"trackFileInfo": "Info File",
"trackLyrics": "Lirik",
"trackFileNotFound": "File tidak ditemukan",
"trackOpenInDeezer": "Buka di Deezer",
"trackOpenInSpotify": "Buka di Spotify",
"trackTrackName": "Nama lagu",
"trackArtist": "Artis",
"trackAlbumArtist": "Artis album",
"trackAlbum": "Album",
"trackTrackNumber": "Nomor lagu",
"trackDiscNumber": "Nomor disc",
"trackDuration": "Durasi",
"trackAudioQuality": "Kualitas audio",
"trackReleaseDate": "Tanggal rilis",
"trackDownloaded": "Diunduh",
"trackCopyLyrics": "Salin lirik",
"trackLyricsNotAvailable": "Lirik tidak tersedia untuk lagu ini",
"trackLyricsTimeout": "Permintaan timeout. Coba lagi nanti.",
"trackLyricsLoadFailed": "Gagal memuat lirik",
"trackCopiedToClipboard": "Disalin ke clipboard",
"trackDeleteConfirmTitle": "Hapus dari perangkat?",
"trackDeleteConfirmMessage": "Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.",
"trackCannotOpen": "Tidak dapat membuka: {message}",
"logFilterBySeverity": "Filter log berdasarkan tingkat keparahan",
"logNoLogsYet": "Belum ada log",
"logNoLogsYetSubtitle": "Log akan muncul di sini saat Anda menggunakan aplikasi",
"logIssueSummary": "Ringkasan Masalah",
"logIspBlockingDescription": "ISP Anda mungkin memblokir akses ke layanan unduhan",
"logIspBlockingSuggestion": "Coba gunakan VPN atau ubah DNS ke 1.1.1.1 atau 8.8.8.8",
"logRateLimitedDescription": "Terlalu banyak permintaan ke layanan",
"logRateLimitedSuggestion": "Tunggu beberapa menit sebelum mencoba lagi",
"logNetworkErrorDescription": "Masalah koneksi terdeteksi",
"logNetworkErrorSuggestion": "Periksa koneksi internet Anda",
"logTrackNotFoundDescription": "Beberapa lagu tidak dapat ditemukan di layanan unduhan",
"logTrackNotFoundSuggestion": "Lagu mungkin tidak tersedia dalam kualitas lossless",
"logTotalErrors": "Total error: {count}",
"logAffected": "Terpengaruh: {domains}",
"logEntriesFiltered": "Entri ({count} difilter)",
"logEntries": "Entri ({count})",
"extensionsProviderPrioritySection": "Prioritas Provider",
"extensionsInstalledSection": "Ekstensi Terpasang",
"extensionsNoExtensions": "Tidak ada ekstensi terpasang",
"extensionsNoExtensionsSubtitle": "Pasang file .spotiflac-ext untuk menambahkan provider baru",
"extensionsInstallButton": "Pasang Ekstensi",
"extensionsInfoTip": "Ekstensi dapat menambahkan provider metadata dan unduhan baru. Hanya pasang ekstensi dari sumber terpercaya.",
"extensionsInstalledSuccess": "Ekstensi berhasil dipasang",
"extensionsDownloadPriority": "Prioritas Unduhan",
"extensionsDownloadPrioritySubtitle": "Atur urutan layanan unduhan",
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
"extensionsMetadataPriority": "Prioritas Metadata",
"extensionsMetadataPrioritySubtitle": "Atur urutan sumber pencarian & metadata",
"extensionsNoMetadataProvider": "Tidak ada ekstensi dengan provider metadata",
"extensionsSearchProvider": "Provider Pencarian",
"extensionsNoCustomSearch": "Tidak ada ekstensi dengan pencarian kustom",
"extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu",
"extensionsCustomSearch": "Pencarian kustom",
"extensionsErrorLoading": "Error memuat ekstensi",
"extensionCustomTrackMatching": "Pencocokan Lagu Kustom",
"extensionPostProcessing": "Pasca-Pemrosesan",
"extensionHooksAvailable": "{count} hook tersedia",
"extensionPatternsCount": "{count} pola",
"extensionStrategy": "Strategi: {strategy}",
"aboutDoubleDouble": "DoubleDouble",
"aboutDoubleDoubleDesc": "API luar biasa untuk unduhan Amazon Music. Terima kasih sudah membuatnya gratis!",
"aboutDabMusic": "DAB Music",
"aboutDabMusicDesc": "API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!",
"queueTitle": "Antrian Unduhan",
"queueClearAll": "Hapus Semua",
"queueClearAllMessage": "Apakah Anda yakin ingin menghapus semua unduhan?",
"albumFolderArtistAlbum": "Artis / Album",
"albumFolderArtistAlbumSubtitle": "Albums/Nama Artis/Nama Album/",
"albumFolderArtistYearAlbum": "Artis / [Tahun] Album",
"albumFolderArtistYearAlbumSubtitle": "Albums/Nama Artis/[2005] Nama Album/",
"albumFolderAlbumOnly": "Album Saja",
"albumFolderAlbumOnlySubtitle": "Albums/Nama Album/",
"albumFolderYearAlbum": "[Tahun] Album",
"albumFolderYearAlbumSubtitle": "Albums/[2005] Nama Album/",
"downloadedAlbumDeleteSelected": "Hapus yang Dipilih",
"downloadedAlbumDeleteMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari album ini?\n\nIni juga akan menghapus file dari penyimpanan.",
"utilityFunctions": "Fungsi Utilitas",
"aboutMobileDeveloper": "Pengembang versi mobile",
"aboutOriginalCreator": "Pembuat SpotiFLAC asli",
"aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kita yang indah!",
"aboutBinimumDesc": "Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!",
"aboutSachinsenalDesc": "Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!",
"aboutMobileSource": "Kode sumber mobile",
"aboutPCSource": "Kode sumber PC",
"aboutReportIssue": "Laporkan masalah",
"aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui",
"aboutFeatureRequest": "Permintaan fitur",
"aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi",
"aboutBuyMeCoffee": "Belikan saya kopi",
"aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi",
"aboutVersion": "Versi",
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
"providerPriorityTitle": "Prioritas Provider",
"providerPriorityDescription": "Seret untuk mengatur ulang urutan provider unduhan. Aplikasi akan mencoba provider dari atas ke bawah saat mengunduh lagu.",
"providerPriorityInfo": "Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.",
"providerBuiltIn": "Bawaan",
"providerExtension": "Ekstensi",
"metadataProviderPriorityTitle": "Prioritas Metadata",
"metadataProviderPriorityDescription": "Seret untuk mengatur ulang urutan provider metadata. Aplikasi akan mencoba provider dari atas ke bawah saat mencari lagu dan mengambil metadata.",
"metadataProviderPriorityInfo": "Deezer tidak memiliki batas rate dan direkomendasikan sebagai utama. Spotify mungkin membatasi rate setelah banyak permintaan.",
"metadataNoRateLimits": "Tidak ada batas rate",
"metadataMayRateLimit": "Mungkin dibatasi rate",
"queueEmpty": "Tidak ada unduhan dalam antrian",
"queueEmptySubtitle": "Tambahkan lagu dari layar beranda",
"queueClearCompleted": "Hapus yang selesai",
"queueDownloadFailed": "Unduhan Gagal",
"queueTrackLabel": "Lagu:",
"queueArtistLabel": "Artis:",
"queueErrorLabel": "Error:",
"queueUnknownError": "Error tidak diketahui",
"downloadedAlbumTracksHeader": "Lagu",
"downloadedAlbumDownloadedCount": "{count} diunduh",
"downloadedAlbumSelectedCount": "{count} dipilih",
"downloadedAlbumAllSelected": "Semua lagu dipilih",
"downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih",
"downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
"downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus",
"folderOrganizationDescription": "Atur file yang diunduh ke dalam folder",
"folderOrganizationNone": "Tidak ada",
"folderOrganizationNoneSubtitle": "Semua file di folder unduhan",
"folderOrganizationByArtist": "Berdasarkan Artis",
"folderOrganizationByArtistSubtitle": "Folder terpisah untuk setiap artis",
"folderOrganizationByAlbum": "Berdasarkan Album",
"folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album",
"folderOrganizationByArtistAlbum": "Berdasarkan Artis & Album",
"folderOrganizationByArtistAlbumSubtitle": "Folder bersarang untuk artis dan album",
"recentTypeArtist": "Artis",
"recentTypeAlbum": "Album",
"recentTypeSong": "Lagu",
"recentTypePlaylist": "Playlist",
"recentPlaylistInfo": "Playlist: {name}",
"errorGeneric": "Error: {message}"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
export 'package:spotiflac_android/l10n/app_localizations.dart';
/// Extension to easily access AppLocalizations from BuildContext
extension AppLocalizationsX on BuildContext {
/// Get the AppLocalizations instance
/// Usage: context.l10n.navHome
AppLocalizations get l10n => AppLocalizations.of(this);
}
+24
View File
@@ -0,0 +1,24 @@
// GENERATED FILE - DO NOT EDIT
// Generated by: dart run tool/check_translations.dart 70
// Only languages with >= 70% translation completion are included.
// Translation is measured by comparing VALUES (not just key existence).
//
// To regenerate, run: dart run tool/check_translations.dart 70
import 'package:flutter/widgets.dart';
/// Minimum translation completion threshold used to filter languages.
const int translationThreshold = 70;
/// List of locales that meet the translation threshold.
/// Only these languages will be available in the app.
const List<Locale> filteredSupportedLocales = <Locale>[
Locale('en'),
Locale('id'),
];
/// Set of locale codes for quick lookup.
const Set<String> filteredLocaleCodes = <String>{
'en',
'id',
};
+34 -3
View File
@@ -1,7 +1,10 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/app.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
@@ -24,14 +27,42 @@ void main() async {
}
/// Widget to eagerly initialize providers that need to load data on startup
class _EagerInitialization extends ConsumerWidget {
class _EagerInitialization extends ConsumerStatefulWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState();
}
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override
void initState() {
super.initState();
_initializeExtensions();
}
Future<void> _initializeExtensions() async {
try {
final appDir = await getApplicationDocumentsDirectory();
final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data';
// Create directories if needed
await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true);
// Initialize extension system
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
} catch (e) {
debugPrint('Failed to initialize extensions: $e');
}
}
@override
Widget build(BuildContext context) {
// Eagerly initialize download history provider to load from storage
ref.watch(downloadHistoryProvider);
return child;
return widget.child;
}
}
+3
View File
@@ -19,6 +19,7 @@ enum DownloadErrorType {
notFound, // Track not found on any service
rateLimit, // Rate limited by service
network, // Network/connection error
permission, // File/folder permission error
}
@JsonSerializable()
@@ -88,6 +89,8 @@ class DownloadItem {
return 'Rate limit reached, try again later';
case DownloadErrorType.network:
return 'Connection failed, check your internet';
case DownloadErrorType.permission:
return 'Cannot write to folder, check storage permission';
default:
return error ?? 'An error occurred';
}
+1
View File
@@ -51,4 +51,5 @@ const _$DownloadErrorTypeEnumMap = {
DownloadErrorType.notFound: 'notFound',
DownloadErrorType.rateLimit: 'rateLimit',
DownloadErrorType.network: 'network',
DownloadErrorType.permission: 'permission',
};
+25
View File
@@ -25,6 +25,12 @@ class AppSettings {
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
final String metadataSource; // spotify, deezer - source for search and metadata
final bool enableLogging; // Enable detailed logging for debugging
final bool useExtensionProviders; // Use extension providers for downloads when available
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
final bool separateSingles; // Separate singles/EPs into their own folder
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
final bool showExtensionStore; // Show Extension Store tab in navigation
final String locale; // App language: 'system', 'en', 'id', etc.
const AppSettings({
this.defaultService = 'tidal',
@@ -48,6 +54,12 @@ class AppSettings {
this.useCustomSpotifyCredentials = true, // Default: use custom if set
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
this.enableLogging = false, // Default: disabled for performance
this.useExtensionProviders = true, // Default: use extensions when available
this.searchProvider, // Default: null (use Deezer/Spotify)
this.separateSingles = false, // Default: disabled
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
this.showExtensionStore = true, // Default: show store
this.locale = 'system', // Default: follow system language
});
AppSettings copyWith({
@@ -72,6 +84,13 @@ class AppSettings {
bool? useCustomSpotifyCredentials,
String? metadataSource,
bool? enableLogging,
bool? useExtensionProviders,
String? searchProvider,
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
bool? separateSingles,
String? albumFolderStructure,
bool? showExtensionStore,
String? locale,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -95,6 +114,12 @@ class AppSettings {
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
);
}
+13
View File
@@ -29,6 +29,13 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['useCustomSpotifyCredentials'] as bool? ?? true,
metadataSource: json['metadataSource'] as String? ?? 'deezer',
enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -54,4 +61,10 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
'metadataSource': instance.metadataSource,
'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders,
'searchProvider': instance.searchProvider,
'separateSingles': instance.separateSingles,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
};
+24
View File
@@ -18,6 +18,9 @@ class Track {
final String? releaseDate;
final String? deezerId;
final ServiceAvailability? availability;
final String? source; // Extension ID that provided this track (null for built-in sources)
final String? albumType; // album, single, ep, compilation (from metadata API)
final String? itemType; // track, album, playlist - for extension search results
const Track({
required this.id,
@@ -33,10 +36,31 @@ class Track {
this.releaseDate,
this.deezerId,
this.availability,
this.source,
this.albumType,
this.itemType,
});
/// Check if this track is a single (based on album_type metadata)
bool get isSingle => albumType == 'single' || albumType == 'ep';
/// Check if this is an album item (not a track)
bool get isAlbumItem => itemType == 'album';
/// Check if this is a playlist item (not a track)
bool get isPlaylistItem => itemType == 'playlist';
/// Check if this is an artist item (not a track)
bool get isArtistItem => itemType == 'artist';
/// Check if this is a collection (album, playlist, or artist)
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this);
/// Check if this track is from an extension
bool get isFromExtension => source != null && source!.isNotEmpty;
}
@JsonSerializable()
+6
View File
@@ -24,6 +24,9 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
: ServiceAvailability.fromJson(
json['availability'] as Map<String, dynamic>,
),
source: json['source'] as String?,
albumType: json['albumType'] as String?,
itemType: json['itemType'] as String?,
);
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
@@ -40,6 +43,9 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'releaseDate': instance.releaseDate,
'deezerId': instance.deezerId,
'availability': instance.availability,
'source': instance.source,
'albumType': instance.albumType,
'itemType': instance.itemType,
};
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
+382 -29
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
@@ -17,6 +18,14 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('DownloadQueue');
final _historyLog = AppLogger('DownloadHistory');
String? _normalizeOptionalString(String? value) {
if (value == null) return null;
final trimmed = value.trim();
if (trimmed.isEmpty) return null;
if (trimmed.toLowerCase() == 'null') return null;
return trimmed;
}
// Download History Item model
class DownloadHistoryItem {
final String id;
@@ -88,7 +97,7 @@ class DownloadHistoryItem {
trackName: json['trackName'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
albumArtist: _normalizeOptionalString(json['albumArtist'] as String?),
coverUrl: json['coverUrl'] as String?,
filePath: json['filePath'] as String,
service: json['service'] as String,
@@ -155,8 +164,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final items = jsonList
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(items: items);
_historyLog.i('Loaded ${items.length} items from storage');
// Deduplicate existing history on load
final deduplicatedItems = _deduplicateHistory(items);
state = state.copyWith(items: deduplicatedItems);
_historyLog.i('Loaded ${deduplicatedItems.length} items from storage (original: ${items.length})');
// Save if duplicates were removed
if (deduplicatedItems.length < items.length) {
_historyLog.i('Removed ${items.length - deduplicatedItems.length} duplicate entries');
await _saveToStorage();
}
} else {
_historyLog.d('No history found in storage');
}
@@ -165,6 +184,46 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
}
/// Deduplicate history items by spotifyId, deezerId, or ISRC
/// Keeps the most recent entry (first occurrence since list is sorted by date desc)
List<DownloadHistoryItem> _deduplicateHistory(List<DownloadHistoryItem> items) {
final seen = <String, int>{}; // key -> index of first occurrence
final result = <DownloadHistoryItem>[];
for (int i = 0; i < items.length; i++) {
final item = items[i];
String? key;
// Generate unique key based on available identifiers
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
// Extract numeric ID for deezer: prefixed IDs
if (item.spotifyId!.startsWith('deezer:')) {
key = 'deezer:${item.spotifyId!.substring(7)}';
} else {
key = 'spotify:${item.spotifyId}';
}
} else if (item.isrc != null && item.isrc!.isNotEmpty) {
key = 'isrc:${item.isrc}';
}
if (key != null) {
if (!seen.containsKey(key)) {
// First occurrence - keep it (most recent since list is sorted by date desc)
seen[key] = result.length;
result.add(item);
} else {
// Duplicate found - skip (keep the first/most recent one)
_historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
}
} else {
// No identifier - keep it (can't deduplicate)
result.add(item);
}
}
return result;
}
Future<void> _saveToStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -182,7 +241,48 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
void addToHistory(DownloadHistoryItem item) {
state = state.copyWith(items: [item, ...state.items]);
// Check if track already exists in history (by spotifyId, deezerId, or ISRC)
final existingIndex = state.items.indexWhere((existing) {
// Match by spotifyId (primary identifier - includes deezer:xxx format)
if (item.spotifyId != null &&
item.spotifyId!.isNotEmpty &&
existing.spotifyId == item.spotifyId) {
return true;
}
// Match Deezer tracks: extract numeric ID from "deezer:123456" format
if (item.spotifyId != null && item.spotifyId!.startsWith('deezer:') &&
existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) {
final itemDeezerId = item.spotifyId!.substring(7); // Remove "deezer:" prefix
final existingDeezerId = existing.spotifyId!.substring(7);
if (itemDeezerId == existingDeezerId) {
return true;
}
}
// Fallback: match by ISRC if spotifyId not available
if (item.isrc != null &&
item.isrc!.isNotEmpty &&
existing.isrc == item.isrc) {
return true;
}
return false;
});
if (existingIndex >= 0) {
// Replace existing entry (update with new download info)
final updatedItems = [...state.items];
updatedItems[existingIndex] = item;
// Move to top of list (most recent)
updatedItems.removeAt(existingIndex);
updatedItems.insert(0, item);
state = state.copyWith(items: updatedItems);
_historyLog.d('Updated existing history entry: ${item.trackName}');
} else {
// Add new entry
state = state.copyWith(items: [item, ...state.items]);
_historyLog.d('Added new history entry: ${item.trackName}');
}
_saveToStorage();
}
@@ -400,6 +500,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
for (final entry in items.entries) {
final itemId = entry.key;
final localItem = state.items
.where((i) => i.id == itemId)
.firstOrNull;
if (localItem == null) {
continue;
}
if (localItem.status == DownloadStatus.skipped) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
continue;
}
if (localItem.status == DownloadStatus.completed ||
localItem.status == DownloadStatus.failed) {
continue;
}
final itemProgress = entry.value as Map<String, dynamic>;
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
@@ -576,35 +690,78 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(outputDir: dir);
}
/// Build output directory based on folder organization setting
Future<String> _buildOutputDir(Track track, String folderOrganization) async {
/// Build output directory based on folder organization setting and separateSingles
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async {
String baseDir = state.outputDir;
final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName;
if (folderOrganization == 'none') {
return baseDir;
// If separateSingles is enabled, use Albums/Singles structure
if (separateSingles) {
final isSingle = track.isSingle;
if (isSingle) {
// Singles go to Singles folder (flat structure)
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
final dir = Directory(singlesPath);
if (!await dir.exists()) {
await dir.create(recursive: true);
_log.d('Created Singles folder: $singlesPath');
}
return singlesPath;
} else {
// Albums folder structure based on setting
final albumName = _sanitizeFolderName(track.albumName);
final artistName = _sanitizeFolderName(albumArtist);
final year = _extractYear(track.releaseDate);
String albumPath;
switch (albumFolderStructure) {
case 'album_only':
// Albums/Album structure (no artist folder)
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
break;
case 'artist_year_album':
// Albums/Artist/[Year] Album structure
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum';
break;
case 'year_album':
// Albums/[Year] Album structure (no artist folder)
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$yearAlbum';
break;
default:
// Albums/Artist/Album structure (default: artist_album)
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
}
final dir = Directory(albumPath);
if (!await dir.exists()) {
await dir.create(recursive: true);
_log.d('Created Album folder: $albumPath');
}
return albumPath;
}
}
// Sanitize folder names (remove invalid characters)
String sanitize(String name) {
return name
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
.replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots
.trim();
// Original folder organization logic (when separateSingles is disabled)
if (folderOrganization == 'none') {
return baseDir;
}
String subPath = '';
switch (folderOrganization) {
case 'artist':
final artistName = sanitize(track.albumArtist ?? track.artistName);
final artistName = _sanitizeFolderName(albumArtist);
subPath = artistName;
break;
case 'album':
final albumName = sanitize(track.albumName);
final albumName = _sanitizeFolderName(track.albumName);
subPath = albumName;
break;
case 'artist_album':
final artistName = sanitize(track.albumArtist ?? track.artistName);
final albumName = sanitize(track.albumName);
final artistName = _sanitizeFolderName(albumArtist);
final albumName = _sanitizeFolderName(track.albumName);
subPath = '$artistName${Platform.pathSeparator}$albumName';
break;
}
@@ -622,6 +779,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return baseDir;
}
/// Sanitize folder names (remove invalid characters)
String _sanitizeFolderName(String name) {
return name
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
.replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots
.trim();
}
/// Extract year from release date (format: "2005-06-13" or "2005")
String? _extractYear(String? releaseDate) {
if (releaseDate == null || releaseDate.isEmpty) return null;
// Handle both "2005-06-13" and "2005" formats
final match = RegExp(r'^(\d{4})').firstMatch(releaseDate);
return match?.group(1);
}
void updateSettings(AppSettings settings) {
state = state.copyWith(
outputDir: settings.downloadDirectory.isNotEmpty
@@ -724,6 +897,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
void updateProgress(String id, double progress, {double? speedMBps}) {
final item = state.items.where((i) => i.id == id).firstOrNull;
if (item == null ||
item.status == DownloadStatus.skipped ||
item.status == DownloadStatus.completed ||
item.status == DownloadStatus.failed) {
return;
}
updateItemStatus(
id,
DownloadStatus.downloading,
@@ -734,6 +914,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
void cancelItem(String id) {
updateItemStatus(id, DownloadStatus.skipped);
PlatformBridge.cancelDownload(id).catchError((_) {});
PlatformBridge.clearItemProgress(id).catchError((_) {});
}
void clearCompleted() {
@@ -831,13 +1013,92 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_saveQueueToStorage(); // Persist queue
}
/// Run post-processing hooks on a downloaded file
Future<void> _runPostProcessingHooks(String filePath, Track track) async {
try {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
// Check if post-processing is enabled and there are extensions with hooks
if (!settings.useExtensionProviders) return;
final hasPostProcessing = extensionState.extensions.any(
(e) => e.enabled && e.hasPostProcessing,
);
if (!hasPostProcessing) return;
_log.d('Running post-processing hooks on: $filePath');
// Build metadata map for post-processing
final metadata = <String, dynamic>{
'title': track.name,
'artist': track.artistName,
'album': track.albumName,
'album_artist': _normalizeOptionalString(track.albumArtist) ?? track.artistName,
'track_number': track.trackNumber ?? 1,
'disc_number': track.discNumber ?? 1,
'isrc': track.isrc ?? '',
'release_date': track.releaseDate ?? '',
'duration_ms': track.duration * 1000,
'cover_url': track.coverUrl ?? '',
};
final result = await PlatformBridge.runPostProcessing(filePath, metadata: metadata);
if (result['success'] == true) {
final hooksRun = result['hooks_run'] as int? ?? 0;
final newPath = result['file_path'] as String?;
_log.i('Post-processing completed: $hooksRun hook(s) executed');
if (newPath != null && newPath != filePath) {
_log.d('File path changed by post-processing: $newPath');
}
} else {
final error = result['error'] as String? ?? 'Unknown error';
_log.w('Post-processing failed: $error');
}
} catch (e) {
_log.w('Post-processing error: $e');
// Don't fail the download if post-processing fails
}
}
/// Upgrade Spotify cover URL to max quality (~2000x2000)
/// Same logic as Go backend cover.go
String _upgradeToMaxQualityCover(String coverUrl) {
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
const spotifySizeMax = 'ab67616d000082c1'; // Max resolution (~2000x2000)
// First upgrade small (300) to medium (640)
var result = coverUrl;
if (result.contains(spotifySize300)) {
result = result.replaceFirst(spotifySize300, spotifySize640);
}
// Then upgrade medium (640) to max
if (result.contains(spotifySize640)) {
result = result.replaceFirst(spotifySize640, spotifySizeMax);
}
return result;
}
/// Embed metadata and cover to a FLAC file after M4A conversion
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
final settings = ref.read(settingsProvider);
// Download cover first
String? coverPath;
final coverUrl = track.coverUrl;
var coverUrl = track.coverUrl;
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
// Upgrade cover URL to max quality if setting is enabled
if (settings.maxQualityCover) {
coverUrl = _upgradeToMaxQualityCover(coverUrl);
_log.d('Cover URL upgraded to max quality: $coverUrl');
}
final tempDir = await getTemporaryDirectory();
final uniqueId =
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
@@ -876,9 +1137,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName,
};
if (track.albumArtist != null) {
metadata['ALBUMARTIST'] = track.albumArtist!;
}
final albumArtist = _normalizeOptionalString(track.albumArtist) ??
track.artistName;
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
@@ -1186,6 +1447,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
_log.d('Cover URL: ${item.track.coverUrl}');
final currentItem = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (currentItem.status == DownloadStatus.skipped) {
_log.i('Download was cancelled before start, skipping');
return;
}
// Set currentDownload for UI reference
state = state.copyWith(currentDownload: item);
@@ -1232,6 +1502,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final data = trackData;
_log.d('Track data keys: ${data.keys.toList()}');
_log.d('ISRC from API: ${data['isrc']}');
_log.d('album_type from API: ${data['album_type']}');
trackToDownload = Track(
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
name: (data['name'] as String?) ?? trackToDownload.name,
@@ -1253,9 +1524,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
releaseDate: data['release_date'] as String?,
deezerId: rawId,
availability: trackToDownload.availability,
// Preserve albumType from API response or original track
albumType: (data['album_type'] as String?) ?? trackToDownload.albumType,
source: trackToDownload.source,
);
_log.d(
'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}',
'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}, AlbumType ${trackToDownload.albumType}',
);
} else {
_log.w('Unexpected track data type: ${trackData.runtimeType}');
@@ -1272,9 +1546,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Log cover URL for debugging CSV import issues
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
final normalizedAlbumArtist =
_normalizeOptionalString(trackToDownload.albumArtist);
final outputDir = await _buildOutputDir(
trackToDownload,
settings.folderOrganization,
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
);
// Use quality override if set, otherwise use default from settings
@@ -1282,7 +1561,37 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Map<String, dynamic> result;
if (state.autoFallback) {
// Check if extension providers should be used
final extensionState = ref.read(extensionProvider);
final hasActiveExtensions = extensionState.extensions.any((e) => e.enabled);
final useExtensions = settings.useExtensionProviders && hasActiveExtensions;
if (useExtensions) {
// Use extension providers (includes fallback to built-in services)
_log.d('Using extension providers for download');
_log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
);
_log.d('Output dir: $outputDir');
result = await PlatformBridge.downloadWithExtensions(
isrc: trackToDownload.isrc ?? '',
spotifyId: trackToDownload.id,
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: quality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
itemId: item.id,
durationMs: trackToDownload.duration,
source: trackToDownload.source, // Pass extension ID that provided this track
);
} else if (state.autoFallback) {
_log.d('Using auto-fallback mode');
_log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
@@ -1294,7 +1603,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
@@ -1315,7 +1624,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
@@ -1356,6 +1665,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
// Strip EXISTS: prefix from duplicate detection
if (filePath != null && filePath.startsWith('EXISTS:')) {
filePath = filePath.substring(7); // Remove "EXISTS:" prefix
}
_log.i('Download success, file: $filePath');
// Get actual quality from response (if available)
@@ -1374,7 +1689,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Actual quality: $actualQuality');
}
// M4A files from Tidal DASH streams - try to convert to FLAC
// M4A files from Tidal DASH streams - try to convert to FLAC
if (filePath != null && filePath.endsWith('.m4a')) {
_log.d(
@@ -1444,7 +1758,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
name: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: backendAlbum ?? trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
duration: trackToDownload.duration,
isrc: trackToDownload.isrc,
@@ -1453,6 +1767,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
releaseDate: backendYear ?? trackToDownload.releaseDate,
deezerId: trackToDownload.deezerId,
availability: trackToDownload.availability,
albumType: trackToDownload.albumType,
source: trackToDownload.source,
);
}
@@ -1502,6 +1818,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
filePath: filePath,
);
// Run post-processing hooks if enabled
if (filePath != null) {
await _runPostProcessingHooks(filePath, trackToDownload);
}
// Increment completed counter
_completedInSession++;
@@ -1528,6 +1849,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Log cover URL for debugging
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
final historyAlbumArtist =
(normalizedAlbumArtist != null &&
normalizedAlbumArtist != trackToDownload.artistName)
? normalizedAlbumArtist
: null;
ref
.read(downloadHistoryProvider.notifier)
.addToHistory(
@@ -1542,7 +1869,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumName: (backendAlbum != null && backendAlbum.isNotEmpty)
? backendAlbum
: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
albumArtist: historyAlbumArtist,
coverUrl: trackToDownload.coverUrl,
filePath: filePath,
service: result['service'] as String? ?? item.service,
@@ -1571,8 +1898,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
removeItem(item.id);
}
} else {
final itemAfterFailure = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (itemAfterFailure.status == DownloadStatus.skipped) {
_log.i('Download was cancelled, skipping error handling');
return;
}
final errorMsg = result['error'] as String? ?? 'Download failed';
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
if (errorTypeStr == 'cancelled') {
_log.i('Download was cancelled by backend, skipping error handling');
updateItemStatus(item.id, DownloadStatus.skipped);
return;
}
// Convert error type string to enum
DownloadErrorType errorType;
@@ -1586,6 +1927,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
case 'network':
errorType = DownloadErrorType.network;
break;
case 'permission':
errorType = DownloadErrorType.permission;
break;
default:
errorType = DownloadErrorType.unknown;
}
@@ -1613,6 +1957,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} catch (e, stackTrace) {
final itemAfterError = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (itemAfterError.status == DownloadStatus.skipped) {
_log.i('Download was cancelled, skipping error handling');
return;
}
_log.e('Exception: $e', e, stackTrace);
String errorMsg = e.toString();
+715
View File
@@ -0,0 +1,715 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
final _log = AppLogger('ExtensionProvider');
/// Represents an installed extension
class Extension {
final String id;
final String name;
final String displayName;
final String version;
final String author;
final String description;
final bool enabled;
final String status; // 'loaded', 'error', 'disabled'
final String? errorMessage;
final String? iconPath; // Path to extension icon
final List<String> permissions;
final List<ExtensionSetting> settings;
final List<QualityOption> qualityOptions; // Custom quality options for download providers
final bool hasMetadataProvider;
final bool hasDownloadProvider;
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final SearchBehavior? searchBehavior; // Custom search behavior
final URLHandler? urlHandler; // Custom URL handling
final TrackMatching? trackMatching; // Custom track matching
final PostProcessing? postProcessing; // Post-processing hooks
const Extension({
required this.id,
required this.name,
required this.displayName,
required this.version,
required this.author,
required this.description,
required this.enabled,
required this.status,
this.errorMessage,
this.iconPath,
this.permissions = const [],
this.settings = const [],
this.qualityOptions = const [],
this.hasMetadataProvider = false,
this.hasDownloadProvider = false,
this.skipMetadataEnrichment = false,
this.searchBehavior,
this.urlHandler,
this.trackMatching,
this.postProcessing,
});
factory Extension.fromJson(Map<String, dynamic> json) {
return Extension(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '',
enabled: json['enabled'] as bool? ?? false,
status: json['status'] as String? ?? 'loaded',
errorMessage: json['error_message'] as String?,
iconPath: json['icon_path'] as String?,
permissions: (json['permissions'] as List<dynamic>?)?.cast<String>() ?? [],
settings: (json['settings'] as List<dynamic>?)
?.map((s) => ExtensionSetting.fromJson(s as Map<String, dynamic>))
.toList() ?? [],
qualityOptions: (json['quality_options'] as List<dynamic>?)
?.map((q) => QualityOption.fromJson(q as Map<String, dynamic>))
.toList() ?? [],
hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false,
hasDownloadProvider: json['has_download_provider'] as bool? ?? false,
skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false,
searchBehavior: json['search_behavior'] != null
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
: null,
urlHandler: json['url_handler'] != null
? URLHandler.fromJson(json['url_handler'] as Map<String, dynamic>)
: null,
trackMatching: json['track_matching'] != null
? TrackMatching.fromJson(json['track_matching'] as Map<String, dynamic>)
: null,
postProcessing: json['post_processing'] != null
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
: null,
);
}
Extension copyWith({
String? id,
String? name,
String? displayName,
String? version,
String? author,
String? description,
bool? enabled,
String? status,
String? errorMessage,
String? iconPath,
List<String>? permissions,
List<ExtensionSetting>? settings,
List<QualityOption>? qualityOptions,
bool? hasMetadataProvider,
bool? hasDownloadProvider,
bool? skipMetadataEnrichment,
SearchBehavior? searchBehavior,
URLHandler? urlHandler,
TrackMatching? trackMatching,
PostProcessing? postProcessing,
}) {
return Extension(
id: id ?? this.id,
name: name ?? this.name,
displayName: displayName ?? this.displayName,
version: version ?? this.version,
author: author ?? this.author,
description: description ?? this.description,
enabled: enabled ?? this.enabled,
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
iconPath: iconPath ?? this.iconPath,
permissions: permissions ?? this.permissions,
settings: settings ?? this.settings,
qualityOptions: qualityOptions ?? this.qualityOptions,
hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider,
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
searchBehavior: searchBehavior ?? this.searchBehavior,
urlHandler: urlHandler ?? this.urlHandler,
trackMatching: trackMatching ?? this.trackMatching,
postProcessing: postProcessing ?? this.postProcessing,
);
}
bool get hasCustomSearch => searchBehavior?.enabled ?? false;
bool get hasURLHandler => urlHandler?.enabled ?? false;
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
bool get hasPostProcessing => postProcessing?.enabled ?? false;
}
/// Custom search behavior configuration
class SearchBehavior {
final bool enabled;
final String? placeholder;
final bool primary;
final String? icon;
final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
final int? thumbnailWidth;
final int? thumbnailHeight;
const SearchBehavior({
required this.enabled,
this.placeholder,
this.primary = false,
this.icon,
this.thumbnailRatio,
this.thumbnailWidth,
this.thumbnailHeight,
});
factory SearchBehavior.fromJson(Map<String, dynamic> json) {
return SearchBehavior(
enabled: json['enabled'] as bool? ?? false,
placeholder: json['placeholder'] as String?,
primary: json['primary'] as bool? ?? false,
icon: json['icon'] as String?,
thumbnailRatio: json['thumbnailRatio'] as String?,
thumbnailWidth: json['thumbnailWidth'] as int?,
thumbnailHeight: json['thumbnailHeight'] as int?,
);
}
/// Get thumbnail size based on configuration
/// Returns (width, height) tuple
(double, double) getThumbnailSize({double defaultSize = 56}) {
// If custom dimensions specified, use them
if (thumbnailWidth != null && thumbnailHeight != null) {
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
}
// Otherwise use ratio presets
switch (thumbnailRatio) {
case 'wide': // 16:9 - YouTube style
return (defaultSize * 16 / 9, defaultSize);
case 'portrait': // 2:3 - Poster style
return (defaultSize * 2 / 3, defaultSize);
case 'square': // 1:1 - Album art style
default:
return (defaultSize, defaultSize);
}
}
}
/// Custom track matching configuration
class TrackMatching {
final bool customMatching;
final String? strategy; // "isrc", "name", "duration", "custom"
final int durationTolerance; // in seconds
const TrackMatching({
required this.customMatching,
this.strategy,
this.durationTolerance = 3,
});
factory TrackMatching.fromJson(Map<String, dynamic> json) {
return TrackMatching(
customMatching: json['customMatching'] as bool? ?? false,
strategy: json['strategy'] as String?,
durationTolerance: json['durationTolerance'] as int? ?? 3,
);
}
}
/// Post-processing configuration
class PostProcessing {
final bool enabled;
final List<PostProcessingHook> hooks;
const PostProcessing({
required this.enabled,
this.hooks = const [],
});
factory PostProcessing.fromJson(Map<String, dynamic> json) {
return PostProcessing(
enabled: json['enabled'] as bool? ?? false,
hooks: (json['hooks'] as List<dynamic>?)
?.map((h) => PostProcessingHook.fromJson(h as Map<String, dynamic>))
.toList() ?? [],
);
}
}
/// URL handler configuration for custom URL patterns
class URLHandler {
final bool enabled;
final List<String> patterns;
const URLHandler({
required this.enabled,
this.patterns = const [],
});
factory URLHandler.fromJson(Map<String, dynamic> json) {
return URLHandler(
enabled: json['enabled'] as bool? ?? false,
patterns: (json['patterns'] as List<dynamic>?)?.cast<String>() ?? [],
);
}
/// Check if a URL matches any of the patterns
bool matchesURL(String url) {
if (!enabled || patterns.isEmpty) return false;
final lowerUrl = url.toLowerCase();
for (final pattern in patterns) {
if (lowerUrl.contains(pattern.toLowerCase())) {
return true;
}
}
return false;
}
}
/// A post-processing hook
class PostProcessingHook {
final String id;
final String name;
final String? description;
final bool defaultEnabled;
final List<String> supportedFormats;
const PostProcessingHook({
required this.id,
required this.name,
this.description,
this.defaultEnabled = false,
this.supportedFormats = const [],
});
factory PostProcessingHook.fromJson(Map<String, dynamic> json) {
return PostProcessingHook(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
description: json['description'] as String?,
defaultEnabled: json['defaultEnabled'] as bool? ?? false,
supportedFormats: (json['supportedFormats'] as List<dynamic>?)?.cast<String>() ?? [],
);
}
}
/// Represents a quality option for download providers
class QualityOption {
final String id;
final String label;
final String? description;
final List<QualitySpecificSetting> settings; // Quality-specific settings
const QualityOption({
required this.id,
required this.label,
this.description,
this.settings = const [],
});
factory QualityOption.fromJson(Map<String, dynamic> json) {
return QualityOption(
id: json['id'] as String? ?? '',
label: json['label'] as String? ?? '',
description: json['description'] as String?,
settings: (json['settings'] as List<dynamic>?)
?.map((s) => QualitySpecificSetting.fromJson(s as Map<String, dynamic>))
.toList() ?? [],
);
}
}
/// Represents a setting that's specific to a quality option
class QualitySpecificSetting {
final String key;
final String label;
final String type; // 'string', 'number', 'boolean', 'select'
final dynamic defaultValue;
final String? description;
final List<String>? options; // For select type
final bool required;
final bool secret;
const QualitySpecificSetting({
required this.key,
required this.label,
required this.type,
this.defaultValue,
this.description,
this.options,
this.required = false,
this.secret = false,
});
factory QualitySpecificSetting.fromJson(Map<String, dynamic> json) {
return QualitySpecificSetting(
key: json['key'] as String? ?? '',
label: json['label'] as String? ?? '',
type: json['type'] as String? ?? 'string',
defaultValue: json['default'],
description: json['description'] as String?,
options: (json['options'] as List<dynamic>?)?.cast<String>(),
required: json['required'] as bool? ?? false,
secret: json['secret'] as bool? ?? false,
);
}
}
/// Represents a setting field for an extension
class ExtensionSetting {
final String key;
final String label;
final String type; // 'string', 'number', 'boolean', 'select'
final dynamic defaultValue;
final String? description;
final List<String>? options; // For select type
final bool required;
const ExtensionSetting({
required this.key,
required this.label,
required this.type,
this.defaultValue,
this.description,
this.options,
this.required = false,
});
factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
return ExtensionSetting(
key: json['key'] as String? ?? '',
label: json['label'] as String? ?? '',
type: json['type'] as String? ?? 'string',
defaultValue: json['default'],
description: json['description'] as String?,
options: (json['options'] as List<dynamic>?)?.cast<String>(),
required: json['required'] as bool? ?? false,
);
}
}
/// State for extension management
class ExtensionState {
final List<Extension> extensions;
final List<String> providerPriority;
final List<String> metadataProviderPriority;
final bool isLoading;
final String? error;
final bool isInitialized;
const ExtensionState({
this.extensions = const [],
this.providerPriority = const [],
this.metadataProviderPriority = const [],
this.isLoading = false,
this.error,
this.isInitialized = false,
});
ExtensionState copyWith({
List<Extension>? extensions,
List<String>? providerPriority,
List<String>? metadataProviderPriority,
bool? isLoading,
String? error,
bool? isInitialized,
}) {
return ExtensionState(
extensions: extensions ?? this.extensions,
providerPriority: providerPriority ?? this.providerPriority,
metadataProviderPriority: metadataProviderPriority ?? this.metadataProviderPriority,
isLoading: isLoading ?? this.isLoading,
error: error,
isInitialized: isInitialized ?? this.isInitialized,
);
}
}
/// Provider for managing extensions
class ExtensionNotifier extends Notifier<ExtensionState> {
@override
ExtensionState build() {
return const ExtensionState();
}
/// Initialize the extension system
Future<void> initialize(String extensionsDir, String dataDir) async {
if (state.isInitialized) return;
state = state.copyWith(isLoading: true, error: null);
try {
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
await loadExtensions(extensionsDir);
await loadProviderPriority();
await loadMetadataProviderPriority();
state = state.copyWith(isInitialized: true, isLoading: false);
_log.i('Extension system initialized');
} catch (e) {
_log.e('Failed to initialize extension system: $e');
state = state.copyWith(isLoading: false, error: e.toString());
}
}
/// Load all extensions from directory
Future<void> loadExtensions(String dirPath) async {
state = state.copyWith(isLoading: true, error: null);
try {
final result = await PlatformBridge.loadExtensionsFromDir(dirPath);
_log.d('Load extensions result: $result');
await refreshExtensions();
state = state.copyWith(isLoading: false);
} catch (e) {
_log.e('Failed to load extensions: $e');
state = state.copyWith(isLoading: false, error: e.toString());
}
}
/// Refresh the list of installed extensions
Future<void> refreshExtensions() async {
try {
final list = await PlatformBridge.getInstalledExtensions();
final extensions = list.map((e) => Extension.fromJson(e)).toList();
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}');
}
}
} catch (e) {
_log.e('Failed to refresh extensions: $e');
state = state.copyWith(error: e.toString());
}
}
/// Clear any error state
void clearError() {
state = state.copyWith(error: null);
}
/// Install extension from file (auto-upgrades if already installed with newer version)
Future<bool> installExtension(String filePath) async {
state = state.copyWith(isLoading: true, error: null);
try {
final result = await PlatformBridge.loadExtensionFromPath(filePath);
_log.i('Installed extension: ${result['name']}');
await refreshExtensions();
state = state.copyWith(isLoading: false);
return true;
} catch (e) {
_log.e('Failed to install extension: $e');
state = state.copyWith(isLoading: false, error: e.toString());
return false;
}
}
/// Check if a package file is an upgrade for an existing extension
/// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed}
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
try {
return await PlatformBridge.checkExtensionUpgrade(filePath);
} catch (e) {
_log.e('Failed to check extension upgrade: $e');
return {'error': e.toString()};
}
}
/// Upgrade an existing extension from a new package file
Future<bool> upgradeExtension(String filePath) async {
state = state.copyWith(isLoading: true, error: null);
try {
final result = await PlatformBridge.upgradeExtension(filePath);
_log.i('Upgraded extension: ${result['display_name']} to v${result['version']}');
await refreshExtensions();
state = state.copyWith(isLoading: false);
return true;
} catch (e) {
_log.e('Failed to upgrade extension: $e');
state = state.copyWith(isLoading: false, error: e.toString());
return false;
}
}
/// Uninstall/remove an extension
Future<bool> removeExtension(String extensionId) async {
state = state.copyWith(isLoading: true, error: null);
try {
await PlatformBridge.removeExtension(extensionId);
_log.i('Removed extension: $extensionId');
await refreshExtensions();
state = state.copyWith(isLoading: false);
return true;
} catch (e) {
_log.e('Failed to remove extension: $e');
state = state.copyWith(isLoading: false, error: e.toString());
return false;
}
}
/// Enable or disable an extension
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
try {
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
_log.d('Set extension $extensionId enabled: $enabled');
// Get extension info before updating state
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
// Update local state
final extensions = state.extensions.map((e) {
if (e.id == extensionId) {
return e.copyWith(enabled: enabled);
}
return e;
}).toList();
state = state.copyWith(extensions: extensions);
// If disabling an extension, reset related settings
if (!enabled && ext != null) {
final settings = ref.read(settingsProvider);
// If this extension was the search provider, clear it and reset to Deezer
if (settings.searchProvider == extensionId) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
}
// If this extension was the default download service, reset to Tidal
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
ref.read(settingsProvider.notifier).setDefaultService('tidal');
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
}
}
} catch (e) {
_log.e('Failed to set extension enabled: $e');
state = state.copyWith(error: e.toString());
}
}
/// Get settings for an extension
Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
try {
return await PlatformBridge.getExtensionSettings(extensionId);
} catch (e) {
_log.e('Failed to get extension settings: $e');
return {};
}
}
/// Update settings for an extension
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
try {
await PlatformBridge.setExtensionSettings(extensionId, settings);
_log.d('Updated settings for extension: $extensionId');
} catch (e) {
_log.e('Failed to set extension settings: $e');
state = state.copyWith(error: e.toString());
}
}
/// Load provider priority order
Future<void> loadProviderPriority() async {
try {
final priority = await PlatformBridge.getProviderPriority();
state = state.copyWith(providerPriority: priority);
} catch (e) {
_log.e('Failed to load provider priority: $e');
}
}
/// Set provider priority order
Future<void> setProviderPriority(List<String> priority) async {
try {
await PlatformBridge.setProviderPriority(priority);
state = state.copyWith(providerPriority: priority);
_log.d('Updated provider priority: $priority');
} catch (e) {
_log.e('Failed to set provider priority: $e');
state = state.copyWith(error: e.toString());
}
}
/// Load metadata provider priority order
Future<void> loadMetadataProviderPriority() async {
try {
final priority = await PlatformBridge.getMetadataProviderPriority();
state = state.copyWith(metadataProviderPriority: priority);
} catch (e) {
_log.e('Failed to load metadata provider priority: $e');
}
}
/// Set metadata provider priority order
Future<void> setMetadataProviderPriority(List<String> priority) async {
try {
await PlatformBridge.setMetadataProviderPriority(priority);
state = state.copyWith(metadataProviderPriority: priority);
_log.d('Updated metadata provider priority: $priority');
} catch (e) {
_log.e('Failed to set metadata provider priority: $e');
state = state.copyWith(error: e.toString());
}
}
/// Cleanup all extensions (call on app close)
Future<void> cleanup() async {
try {
await PlatformBridge.cleanupExtensions();
_log.d('Extensions cleaned up');
} catch (e) {
_log.e('Failed to cleanup extensions: $e');
}
}
/// Get extension by ID
Extension? getExtension(String extensionId) {
try {
return state.extensions.firstWhere((ext) => ext.id == extensionId);
} catch (_) {
return null;
}
}
/// Get all enabled extensions
List<Extension> get enabledExtensions {
return state.extensions.where((ext) => ext.enabled).toList();
}
/// Get all download providers (built-in + extensions)
List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz', 'amazon'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasDownloadProvider) {
providers.add(ext.id);
}
}
return providers;
}
/// Get all metadata providers (built-in + extensions)
List<String> getAllMetadataProviders() {
final providers = ['deezer', 'spotify'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasMetadataProvider) {
providers.add(ext.id);
}
}
return providers;
}
/// Get all extensions that provide custom search
List<Extension> get searchProviders {
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
}
}
final extensionProvider = NotifierProvider<ExtensionNotifier, ExtensionState>(
ExtensionNotifier.new,
);
+248
View File
@@ -0,0 +1,248 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _recentAccessKey = 'recent_access_history';
const _maxRecentItems = 20;
/// Types of items that can be accessed
enum RecentAccessType {
artist,
album,
track,
playlist,
}
/// Represents a recently accessed item
class RecentAccessItem {
final String id;
final String name;
final String? subtitle; // Artist name for tracks/albums, null for artists
final String? imageUrl;
final RecentAccessType type;
final DateTime accessedAt;
final String? providerId; // Extension ID or 'deezer' for built-in
const RecentAccessItem({
required this.id,
required this.name,
this.subtitle,
this.imageUrl,
required this.type,
required this.accessedAt,
this.providerId,
});
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'subtitle': subtitle,
'imageUrl': imageUrl,
'type': type.name,
'accessedAt': accessedAt.toIso8601String(),
'providerId': providerId,
};
factory RecentAccessItem.fromJson(Map<String, dynamic> json) {
return RecentAccessItem(
id: json['id'] as String,
name: json['name'] as String,
subtitle: json['subtitle'] as String?,
imageUrl: json['imageUrl'] as String?,
type: RecentAccessType.values.firstWhere(
(e) => e.name == json['type'],
orElse: () => RecentAccessType.track,
),
accessedAt: DateTime.parse(json['accessedAt'] as String),
providerId: json['providerId'] as String?,
);
}
/// Create a unique key for deduplication
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RecentAccessItem &&
runtimeType == other.runtimeType &&
uniqueKey == other.uniqueKey;
@override
int get hashCode => uniqueKey.hashCode;
}
/// State for recent access history
class RecentAccessState {
final List<RecentAccessItem> items;
final bool isLoaded;
const RecentAccessState({
this.items = const [],
this.isLoaded = false,
});
RecentAccessState copyWith({
List<RecentAccessItem>? items,
bool? isLoaded,
}) {
return RecentAccessState(
items: items ?? this.items,
isLoaded: isLoaded ?? this.isLoaded,
);
}
}
/// Provider for managing recent access history
class RecentAccessNotifier extends Notifier<RecentAccessState> {
@override
RecentAccessState build() {
_loadHistory();
return const RecentAccessState();
}
Future<void> _loadHistory() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_recentAccessKey);
if (json != null) {
try {
final List<dynamic> decoded = jsonDecode(json);
final items = decoded
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(items: items, isLoaded: true);
} catch (e) {
// Invalid JSON, start fresh
state = state.copyWith(isLoaded: true);
}
} else {
state = state.copyWith(isLoaded: true);
}
}
Future<void> _saveHistory() async {
final prefs = await SharedPreferences.getInstance();
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
await prefs.setString(_recentAccessKey, json);
}
/// Record an access to an artist
void recordArtistAccess({
required String id,
required String name,
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
imageUrl: imageUrl,
type: RecentAccessType.artist,
accessedAt: DateTime.now(),
providerId: providerId,
));
}
/// Record an access to an album
void recordAlbumAccess({
required String id,
required String name,
String? artistName,
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
subtitle: artistName,
imageUrl: imageUrl,
type: RecentAccessType.album,
accessedAt: DateTime.now(),
providerId: providerId,
));
}
/// Record an access to a track
void recordTrackAccess({
required String id,
required String name,
String? artistName,
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
subtitle: artistName,
imageUrl: imageUrl,
type: RecentAccessType.track,
accessedAt: DateTime.now(),
providerId: providerId,
));
}
/// Record an access to a playlist
void recordPlaylistAccess({
required String id,
required String name,
String? ownerName,
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
subtitle: ownerName,
imageUrl: imageUrl,
type: RecentAccessType.playlist,
accessedAt: DateTime.now(),
providerId: providerId,
));
}
void _recordAccess(RecentAccessItem item) {
// Debug log
// ignore: avoid_print
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
// Remove any existing entry with same unique key
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
.toList();
// Add new item at the beginning
updatedItems.insert(0, item);
// Limit to max items
if (updatedItems.length > _maxRecentItems) {
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
}
state = state.copyWith(items: updatedItems);
_saveHistory();
// Debug log
// ignore: avoid_print
print('[RecentAccess] Total items now: ${updatedItems.length}');
}
/// Remove a specific item from history
void removeItem(RecentAccessItem item) {
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
.toList();
state = state.copyWith(items: updatedItems);
_saveHistory();
}
/// Clear all history
void clearHistory() {
state = state.copyWith(items: []);
_saveHistory();
}
}
/// Provider instance
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
RecentAccessNotifier.new,
);
+38 -6
View File
@@ -60,18 +60,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
/// Apply current Spotify credentials to Go backend
Future<void> _applySpotifyCredentials() async {
// Only apply custom credentials if enabled and both fields are set
if (state.useCustomSpotifyCredentials &&
state.spotifyClientId.isNotEmpty &&
// Only apply if both fields are set
if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials(
state.spotifyClientId,
state.spotifyClientSecret,
);
} else {
// Clear to use default
await PlatformBridge.setSpotifyCredentials('', '');
}
// Note: If credentials are empty, Spotify API will return error
// User should use Deezer as metadata source instead
}
void setDefaultService(String service) {
@@ -197,12 +195,46 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setSearchProvider(String? provider) {
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearSearchProvider: true);
} else {
state = state.copyWith(searchProvider: provider);
}
_saveSettings();
}
void setEnableLogging(bool enabled) {
state = state.copyWith(enableLogging: enabled);
_saveSettings();
// Sync logging state to LogBuffer
LogBuffer.loggingEnabled = enabled;
}
void setUseExtensionProviders(bool enabled) {
state = state.copyWith(useExtensionProviders: enabled);
_saveSettings();
}
void setSeparateSingles(bool enabled) {
state = state.copyWith(separateSingles: enabled);
_saveSettings();
}
void setAlbumFolderStructure(String structure) {
state = state.copyWith(albumFolderStructure: structure);
_saveSettings();
}
void setShowExtensionStore(bool enabled) {
state = state.copyWith(showExtensionStore: enabled);
_saveSettings();
}
void setLocale(String locale) {
state = state.copyWith(locale: locale);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+316
View File
@@ -0,0 +1,316 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('StoreProvider');
/// Compare two semantic version strings
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.');
final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.');
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
for (var i = 0; i < maxLen; i++) {
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
if (n1 < n2) return -1;
if (n1 > n2) return 1;
}
return 0;
}
/// Extension categories
class StoreCategory {
static const String metadata = 'metadata';
static const String download = 'download';
static const String utility = 'utility';
static const String lyrics = 'lyrics';
static const String integration = 'integration';
static const List<String> all = [metadata, download, utility, lyrics, integration];
static String getDisplayName(String category) {
switch (category) {
case metadata:
return 'Metadata';
case download:
return 'Download';
case utility:
return 'Utility';
case lyrics:
return 'Lyrics';
case integration:
return 'Integration';
default:
return category;
}
}
}
/// Represents an extension in the store
class StoreExtension {
final String id;
final String name;
final String displayName;
final String version;
final String author;
final String description;
final String downloadUrl;
final String? iconUrl;
final String category;
final List<String> tags;
final int downloads;
final String updatedAt;
final String? minAppVersion;
final bool isInstalled;
final String? installedVersion;
final bool hasUpdate;
const StoreExtension({
required this.id,
required this.name,
required this.displayName,
required this.version,
required this.author,
required this.description,
required this.downloadUrl,
this.iconUrl,
required this.category,
this.tags = const [],
this.downloads = 0,
required this.updatedAt,
this.minAppVersion,
this.isInstalled = false,
this.installedVersion,
this.hasUpdate = false,
});
factory StoreExtension.fromJson(Map<String, dynamic> json) {
return StoreExtension(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '',
downloadUrl: json['download_url'] as String? ?? '',
iconUrl: json['icon_url'] as String?,
category: json['category'] as String? ?? 'utility',
tags: (json['tags'] as List<dynamic>?)?.cast<String>() ?? [],
downloads: json['downloads'] as int? ?? 0,
updatedAt: json['updated_at'] as String? ?? '',
minAppVersion: json['min_app_version'] as String?,
isInstalled: json['is_installed'] as bool? ?? false,
installedVersion: json['installed_version'] as String?,
hasUpdate: json['has_update'] as bool? ?? false,
);
}
/// 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;
}
}
/// State for extension store
class StoreState {
final List<StoreExtension> extensions;
final String? selectedCategory;
final String searchQuery;
final bool isLoading;
final bool isDownloading;
final String? downloadingId;
final String? error;
final bool isInitialized;
const StoreState({
this.extensions = const [],
this.selectedCategory,
this.searchQuery = '',
this.isLoading = false,
this.isDownloading = false,
this.downloadingId,
this.error,
this.isInitialized = false,
});
StoreState copyWith({
List<StoreExtension>? extensions,
String? selectedCategory,
bool clearCategory = false,
String? searchQuery,
bool? isLoading,
bool? isDownloading,
String? downloadingId,
bool clearDownloadingId = false,
String? error,
bool clearError = false,
bool? isInitialized,
}) {
return StoreState(
extensions: extensions ?? this.extensions,
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory),
searchQuery: searchQuery ?? this.searchQuery,
isLoading: isLoading ?? this.isLoading,
isDownloading: isDownloading ?? this.isDownloading,
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
error: clearError ? null : (error ?? this.error),
isInitialized: isInitialized ?? this.isInitialized,
);
}
/// Get filtered extensions based on category and search
List<StoreExtension> get filteredExtensions {
var result = extensions;
if (selectedCategory != null) {
result = result.where((e) => e.category == selectedCategory).toList();
}
if (searchQuery.isNotEmpty) {
final query = searchQuery.toLowerCase();
result = result.where((e) =>
e.name.toLowerCase().contains(query) ||
e.displayName.toLowerCase().contains(query) ||
e.description.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query))
).toList();
}
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() {
return const StoreState();
}
/// Initialize the store
Future<void> initialize(String cacheDir) async {
if (state.isInitialized) return;
state = state.copyWith(isLoading: true, clearError: true);
try {
await PlatformBridge.initExtensionStore(cacheDir);
await refresh();
state = state.copyWith(isInitialized: true, isLoading: false);
_log.i('Extension store initialized');
} catch (e) {
_log.e('Failed to initialize store: $e');
state = state.copyWith(isLoading: false, error: e.toString());
}
}
/// Refresh extensions from store
Future<void> refresh({bool forceRefresh = false}) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh);
state = state.copyWith(
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
isLoading: false,
);
_log.d('Loaded ${state.extensions.length} extensions from store');
} catch (e) {
_log.e('Failed to refresh store: $e');
state = state.copyWith(isLoading: false, error: e.toString());
}
}
/// Set category filter
void setCategory(String? category) {
if (category == null) {
state = state.copyWith(clearCategory: true);
} else {
state = state.copyWith(selectedCategory: category);
}
}
/// Set search query
void setSearchQuery(String query) {
state = state.copyWith(searchQuery: query);
}
/// Clear search
void clearSearch() {
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);
try {
_log.i('Downloading extension: $extensionId');
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
_log.i('Installing extension from: $downloadPath');
final extNotifier = ref.read(extensionProvider.notifier);
final success = await extNotifier.installExtension(downloadPath);
if (success) {
_log.i('Extension installed: $extensionId');
await refresh();
}
state = state.copyWith(isDownloading: false, clearDownloadingId: true);
return success;
} catch (e) {
_log.e('Failed to install extension: $e');
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
return false;
}
}
/// Update an installed extension
Future<bool> updateExtension(String extensionId, String tempDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
try {
_log.i('Downloading update for: $extensionId');
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
_log.i('Upgrading extension from: $downloadPath');
final extNotifier = ref.read(extensionProvider.notifier);
final success = await extNotifier.upgradeExtension(downloadPath);
if (success) {
_log.i('Extension updated: $extensionId');
await refresh();
}
state = state.copyWith(isDownloading: false, clearDownloadingId: true);
return success;
} catch (e) {
_log.e('Failed to update extension: $e');
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
return false;
}
}
/// Clear error
void clearError() {
state = state.copyWith(clearError: true);
}
}
final storeProvider = NotifierProvider<StoreNotifier, StoreState>(
StoreNotifier.new,
);
+222 -6
View File
@@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('TrackProvider');
@@ -15,9 +17,14 @@ class TrackState {
final String? artistId;
final String? artistName;
final String? coverUrl;
final String? headerImageUrl; // Artist header image for background
final int? monthlyListeners; // Artist monthly listeners
final List<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks
final List<SearchArtist>? searchArtists; // For search results
final bool hasSearchText; // For back button handling
final bool isShowingRecentAccess; // For recent access mode
final String? searchExtensionId; // Extension ID used for current search results
const TrackState({
this.tracks = const [],
@@ -29,9 +36,14 @@ class TrackState {
this.artistId,
this.artistName,
this.coverUrl,
this.headerImageUrl,
this.monthlyListeners,
this.artistAlbums,
this.artistTopTracks,
this.searchArtists,
this.hasSearchText = false,
this.isShowingRecentAccess = false,
this.searchExtensionId,
});
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
@@ -46,9 +58,14 @@ class TrackState {
String? artistId,
String? artistName,
String? coverUrl,
String? headerImageUrl,
int? monthlyListeners,
List<ArtistAlbum>? artistAlbums,
List<Track>? artistTopTracks,
List<SearchArtist>? searchArtists,
bool? hasSearchText,
bool? isShowingRecentAccess,
String? searchExtensionId,
}) {
return TrackState(
tracks: tracks ?? this.tracks,
@@ -60,9 +77,14 @@ class TrackState {
artistId: artistId ?? this.artistId,
artistName: artistName ?? this.artistName,
coverUrl: coverUrl ?? this.coverUrl,
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
artistAlbums: artistAlbums ?? this.artistAlbums,
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
searchArtists: searchArtists ?? this.searchArtists,
hasSearchText: hasSearchText ?? this.hasSearchText,
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
searchExtensionId: searchExtensionId,
);
}
}
@@ -76,6 +98,7 @@ class ArtistAlbum {
final String? coverUrl;
final String albumType; // album, single, compilation
final String artists;
final String? providerId; // Extension ID if from extension
const ArtistAlbum({
required this.id,
@@ -85,6 +108,7 @@ class ArtistAlbum {
this.coverUrl,
required this.albumType,
required this.artists,
this.providerId,
});
}
@@ -125,6 +149,67 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// First, check if any extension can handle this URL
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;
if (result != null) {
final type = result['type'] as String?;
final extensionId = result['extension_id'] as String?;
if (type == 'track' && result['track'] != null) {
final trackData = result['track'] as Map<String, dynamic>;
final track = _parseSearchTrack(trackData, source: extensionId);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
searchExtensionId: extensionId,
);
return;
} else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) {
final trackList = result['tracks'] as List<dynamic>;
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: result['album']?['id'] as String?,
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
playlistName: type == 'playlist' ? result['name'] as String? : null,
coverUrl: result['cover_url'] as String?,
searchExtensionId: extensionId,
);
return;
} else if (type == 'artist' && result['artist'] != null) {
final artistData = result['artist'] as Map<String, dynamic>;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
// Parse top tracks if available
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistData['id'] as String?,
artistName: artistData['name'] as String?,
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
headerImageUrl: artistData['header_image'] as String?,
monthlyListeners: artistData['listeners'] as int?,
artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
searchExtensionId: extensionId,
);
return;
}
}
}
// No extension handler found, try Spotify URL parsing
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return; // Request cancelled
@@ -210,12 +295,50 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// Check if extension providers should be used for search
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final hasActiveMetadataExtensions = extensionState.extensions.any(
(e) => e.enabled && e.hasMetadataProvider,
);
final searchProvider = settings.searchProvider;
final useExtensions =
settings.useExtensionProviders &&
hasActiveMetadataExtensions &&
searchProvider != null &&
searchProvider.isNotEmpty;
// Use Deezer or Spotify based on settings
final source = metadataSource ?? 'deezer';
_log.i('Search started: source=$source, query="$query"');
_log.i(
'Search started: source=$source, query="$query", useExtensions=$useExtensions',
);
Map<String, dynamic> results;
List<Track> extensionTracks = [];
// Try extension providers first if enabled
if (useExtensions) {
try {
_log.d('Calling extension search API...');
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
_log.i('Extensions returned ${extResults.length} tracks');
// Parse extension results
for (final t in extResults) {
try {
extensionTracks.add(_parseSearchTrack(t));
} catch (e) {
_log.e('Failed to parse extension track: $e', e);
}
}
} catch (e) {
_log.w('Extension search failed, falling back to built-in: $e');
}
}
// Also search with built-in providers
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
@@ -238,11 +361,26 @@ class TrackNotifier extends Notifier<TrackState> {
// Parse tracks with error handling per item
final tracks = <Track>[];
// Add extension tracks first (they have priority)
tracks.addAll(extensionTracks);
// Add built-in provider tracks, avoiding duplicates by ISRC
final existingIsrcs = extensionTracks
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
.map((t) => t.isrc!)
.toSet();
for (int i = 0; i < trackList.length; i++) {
final t = trackList[i];
try {
if (t is Map<String, dynamic>) {
tracks.add(_parseSearchTrack(t));
final track = _parseSearchTrack(t);
// Skip if we already have this track from extensions
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
continue;
}
tracks.add(track);
} else {
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
}
@@ -266,7 +404,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
_log.i('Search complete: ${tracks.length} tracks, ${artists.length} artists parsed successfully');
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists parsed successfully');
state = TrackState(
tracks: tracks,
@@ -281,6 +419,53 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
/// Perform custom search using a specific extension
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Preserve hasSearchText during search
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
_log.i('Custom search started: extension=$extensionId, query="$query"');
final results = await PlatformBridge.customSearchWithExtension(extensionId, query, options: options);
if (!_isRequestValid(requestId)) {
_log.w('Custom search request cancelled (requestId=$requestId)');
return;
}
_log.i('Custom search returned ${results.length} tracks');
// Parse tracks with error handling per item, setting source to extension ID
final tracks = <Track>[];
for (int i = 0; i < results.length; i++) {
final t = results[i];
try {
tracks.add(_parseSearchTrack(t, source: extensionId));
} catch (e) {
_log.e('Failed to parse custom search track[$i]: $e', e);
}
}
_log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)');
state = TrackState(
tracks: tracks,
searchArtists: [], // Custom search doesn't return artists
isLoading: false,
hasSearchText: state.hasSearchText,
searchExtensionId: extensionId, // Store which extension was used
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
_log.e('Custom search failed: $e', e, stackTrace);
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
}
}
Future<void> checkAvailability(int index) async {
if (index < 0 || index >= state.tracks.length) return;
@@ -301,6 +486,8 @@ class TrackNotifier extends Notifier<TrackState> {
trackNumber: track.trackNumber,
discNumber: track.discNumber,
releaseDate: track.releaseDate,
albumType: track.albumType,
source: track.source,
availability: ServiceAvailability(
tidal: availability['tidal'] as bool? ?? false,
qobuz: availability['qobuz'] as bool? ?? false,
@@ -327,6 +514,28 @@ class TrackNotifier extends Notifier<TrackState> {
void setSearchText(bool hasText) {
state = state.copyWith(hasSearchText: hasText);
}
/// Set recent access mode state
void setShowingRecentAccess(bool showing) {
state = state.copyWith(isShowingRecentAccess: showing);
}
/// Set tracks from a collection (album/playlist) opened from search results
void setTracksFromCollection({
required List<Track> tracks,
String? albumName,
String? playlistName,
String? coverUrl,
}) {
state = TrackState(
tracks: tracks,
isLoading: false,
albumName: albumName,
playlistName: playlistName,
coverUrl: coverUrl,
hasSearchText: state.hasSearchText,
);
}
Track _parseTrack(Map<String, dynamic> data) {
return Track(
@@ -344,7 +553,7 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
Track _parseSearchTrack(Map<String, dynamic> data) {
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
// Handle duration_ms which might be int or double
int durationMs = 0;
final durationValue = data['duration_ms'];
@@ -354,18 +563,24 @@ class TrackNotifier extends Notifier<TrackState> {
durationMs = durationValue.toInt();
}
// Get item_type - can be 'track', 'album', or 'playlist'
final itemType = data['item_type']?.toString();
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
coverUrl: data['images']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
albumType: data['album_type']?.toString(),
itemType: itemType,
);
}
@@ -375,9 +590,10 @@ class TrackNotifier extends Notifier<TrackState> {
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['images'] as String?,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(),
);
}
+43 -226
View File
@@ -2,11 +2,14 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
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/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
/// Simple in-memory cache for album tracks
class _AlbumCache {
@@ -61,6 +64,19 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
@override
void initState() {
super.initState();
// Record access for recent history
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
artistName: widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
});
// Priority: widget.tracks > cache > fetch
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
if (_tracks == null) {
@@ -259,7 +275,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -268,7 +284,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
@@ -288,7 +304,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
],
),
),
@@ -316,13 +332,19 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
DownloadServicePicker.show(
context,
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
}
}
@@ -331,84 +353,21 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (tracks == null || tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
artistName: widget.albumName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
}
}
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
String selectedService = settings.defaultService;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
// Service selector
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
const SizedBox(width: 8),
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
const SizedBox(width: 8),
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
const SizedBox(height: 16),
],
),
),
),
),
);
}
/// Build error widget with special handling for rate limit (429)
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
@@ -431,7 +390,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
context.l10n.errorRateLimited,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
@@ -439,7 +398,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment and try again.',
context.l10n.errorRateLimitedMessage,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
@@ -473,148 +432,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
class _QualityOption extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final VoidCallback onTap;
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
onTap: onTap,
);
}
}
class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
final String? coverUrl;
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
@override
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
}
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
bool _expanded = false;
bool _isOverflowing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
child: Column(
children: [
const SizedBox(height: 8),
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: widget.coverUrl != null
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
),
const SizedBox(width: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
final titleOverflows = titlePainter.didExceedMaxLines;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _isOverflowing != titleOverflows) {
setState(() => _isOverflowing = titleOverflows);
}
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.trackName,
style: titleStyle,
maxLines: _expanded ? 10 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
if (widget.artistName != null) ...[
const SizedBox(height: 2),
Text(
widget.artistName!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: _expanded ? 3 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
],
],
);
},
),
),
if (_isOverflowing || _expanded)
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
],
),
),
],
),
),
);
}
}
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
class _AlbumTrackItem extends ConsumerWidget {
final Track track;
@@ -674,7 +491,7 @@ class _AlbumTrackItem extends ConsumerWidget {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
}
return;
} else {
+632 -172
View File
@@ -1,50 +1,87 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:intl/intl.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
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/services/platform_bridge.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
/// Simple in-memory cache for artist discography
/// Simple in-memory cache for artist data
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
static List<ArtistAlbum>? get(String artistId) {
static _CacheEntry? get(String artistId) {
final entry = _cache[artistId];
if (entry == null) return null;
if (DateTime.now().isAfter(entry.expiresAt)) {
_cache.remove(artistId);
return null;
}
return entry.albums;
return entry;
}
static void set(String artistId, List<ArtistAlbum> albums) {
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
static void set(String artistId, {
required List<ArtistAlbum> albums,
List<Track>? topTracks,
String? headerImageUrl,
int? monthlyListeners,
}) {
_cache[artistId] = _CacheEntry(
albums: albums,
topTracks: topTracks,
headerImageUrl: headerImageUrl,
monthlyListeners: monthlyListeners,
expiresAt: DateTime.now().add(_ttl),
);
}
}
class _CacheEntry {
final List<ArtistAlbum> albums;
final List<Track>? topTracks;
final String? headerImageUrl;
final int? monthlyListeners;
final DateTime expiresAt;
_CacheEntry(this.albums, this.expiresAt);
_CacheEntry({
required this.albums,
this.topTracks,
this.headerImageUrl,
this.monthlyListeners,
required this.expiresAt,
});
}
/// Artist screen with Material Expressive 3 design - shows discography
/// Artist screen with Spotify-like design
class ArtistScreen extends ConsumerStatefulWidget {
final String artistId;
final String artistName;
final String? coverUrl;
final List<ArtistAlbum>? albums; // Optional - will fetch if null
final String? headerImageUrl;
final int? monthlyListeners;
final List<ArtistAlbum>? albums;
final List<Track>? topTracks;
final String? extensionId; // If set, skip fetching from Spotify/Deezer
const ArtistScreen({
super.key,
required this.artistId,
required this.artistName,
this.coverUrl,
this.headerImageUrl,
this.monthlyListeners,
this.albums,
this.topTracks,
this.extensionId,
});
@override
@@ -54,14 +91,62 @@ class ArtistScreen extends ConsumerStatefulWidget {
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _isLoadingDiscography = false;
List<ArtistAlbum>? _albums;
List<Track>? _topTracks;
String? _headerImageUrl;
int? _monthlyListeners;
String? _error;
@override
void initState() {
super.initState();
// Priority: widget.albums > cache > fetch
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
if (_albums == null) {
// Record access for recent history
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.extensionId ??
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
ref.read(recentAccessProvider.notifier).recordArtistAccess(
id: widget.artistId,
name: widget.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
});
// If this is an extension artist, use provided data only - don't fetch from Spotify/Deezer
if (widget.extensionId != null) {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_monthlyListeners = widget.monthlyListeners;
// Extension artists don't need additional fetching
return;
}
// Priority: widget data > cache > fetch
// But always fetch if topTracks is missing (to get popular tracks)
final cached = _ArtistCache.get(widget.artistId);
if (widget.albums != null) {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_monthlyListeners = widget.monthlyListeners;
// If we have albums but no top tracks, fetch to get them
if (_topTracks == null || _topTracks!.isEmpty) {
_fetchDiscography();
}
} else if (cached != null) {
_albums = cached.albums;
_topTracks = cached.topTracks;
_headerImageUrl = cached.headerImageUrl;
_monthlyListeners = cached.monthlyListeners;
// If cache has no top tracks, fetch
if (_topTracks == null || _topTracks!.isEmpty) {
_fetchDiscography();
}
} else {
_fetchDiscography();
}
}
@@ -70,31 +155,60 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
setState(() => _isLoadingDiscography = true);
try {
List<ArtistAlbum> albums;
List<Track>? topTracks;
String? headerImage;
int? listeners;
// Check if this is a Deezer artist ID (format: "deezer:123456")
if (widget.artistId.startsWith('deezer:')) {
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
// ignore: avoid_print
print('[ArtistScreen] Fetching from Deezer: $deezerArtistId');
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
} else {
// Spotify artist - use fallback method
// ignore: avoid_print
print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}');
// Spotify artist - use extension handler via URL
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
final result = await PlatformBridge.handleURLWithExtension(url);
if (result != null && result['artist'] != null) {
final artistData = result['artist'] as Map<String, dynamic>;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
// Parse top tracks if available
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) {
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
}
headerImage = artistData['header_image'] as String?;
listeners = artistData['listeners'] as int?;
} else {
// Fallback to Spotify API metadata
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
}
}
// Store in cache
_ArtistCache.set(widget.artistId, albums);
// Store in cache (preserve existing values if new ones are null)
final finalHeaderImage = headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
final finalListeners = listeners ?? _monthlyListeners ?? widget.monthlyListeners;
_ArtistCache.set(
widget.artistId,
albums: albums,
topTracks: topTracks,
headerImageUrl: finalHeaderImage,
monthlyListeners: finalListeners,
);
if (mounted) {
setState(() {
_albums = albums;
_topTracks = topTracks;
_headerImageUrl = finalHeaderImage;
_monthlyListeners = finalListeners;
_isLoadingDiscography = false;
});
}
@@ -108,15 +222,41 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
}
Track _parseTrack(Map<String, dynamic> data) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
source: data['provider_id']?.toString(),
);
}
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
return ArtistAlbum(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['images'] as String?,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(),
);
}
@@ -129,43 +269,63 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
return Scaffold(
body: Stack(
children: [
CustomScrollView(
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
if (_isLoadingDiscography)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: _buildErrorWidget(_error!, colorScheme),
)),
if (!_isLoadingDiscography && _error == null) ...[
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
body: CustomScrollView(
slivers: [
_buildHeader(context, colorScheme),
if (_isLoadingDiscography)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: _buildErrorWidget(_error!, colorScheme),
)),
if (!_isLoadingDiscography && _error == null) ...[
// Popular tracks section
if (_topTracks != null && _topTracks!.isNotEmpty)
SliverToBoxAdapter(child: _buildPopularSection(colorScheme)),
// Discography sections
if (albumsOnly.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)),
if (singles.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistSingles, singles, colorScheme)),
if (compilations.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
// Validate image URL - must be non-null, non-empty, and have a valid host
final hasValidImage = widget.coverUrl != null &&
widget.coverUrl!.isNotEmpty &&
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
/// Build Spotify-style header with full-width image and artist name overlay
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
// Use header image if available, otherwise fall back to cover URL
// Prefer: fetched header > widget header > widget cover
String? imageUrl = _headerImageUrl;
if (imageUrl == null || imageUrl.isEmpty) {
imageUrl = widget.headerImageUrl;
}
if (imageUrl == null || imageUrl.isEmpty) {
imageUrl = widget.coverUrl;
}
final hasValidImage = imageUrl != null &&
imageUrl.isNotEmpty &&
Uri.tryParse(imageUrl)?.hasAuthority == true;
// Format monthly listeners
String? listenersText;
final listeners = _monthlyListeners ?? widget.monthlyListeners;
if (listeners != null && listeners > 0) {
final formatter = NumberFormat.compact();
listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners));
}
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 380,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
@@ -174,49 +334,84 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Background image - full width, no circular crop
if (hasValidImage)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
imageUrl: imageUrl,
fit: BoxFit.cover,
alignment: Alignment.topCenter, // Show top of image (faces)
memCacheWidth: 800,
placeholder: (context, url) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant),
),
)
else
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant),
),
// Gradient overlay for text readability
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
stops: const [0.0, 0.7, 1.0],
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
Colors.black.withValues(alpha: 0.7),
colorScheme.surface,
],
stops: const [0.0, 0.5, 0.75, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
// Artist name and listeners at bottom
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.artistName,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 4,
color: Colors.black.withValues(alpha: 0.5),
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
child: ClipOval(
child: hasValidImage
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: 280,
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
),
)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
),
),
if (listenersText != null) ...[
const SizedBox(height: 4),
Text(
listenersText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.8),
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 2,
color: Colors.black.withValues(alpha: 0.5),
),
],
),
),
],
],
),
),
],
@@ -224,44 +419,280 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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)),
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
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.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
const SizedBox(height: 8),
if (_albums != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${_albums!.length} releases', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
],
/// Build Popular tracks section like Spotify
Widget _buildPopularSection(ColorScheme colorScheme) {
if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink();
// Show max 5 tracks
final tracks = _topTracks!.take(5).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Text(
context.l10n.artistPopular,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...tracks.asMap().entries.map((entry) {
final index = entry.key;
final track = entry.value;
return _buildPopularTrackItem(index + 1, track, colorScheme);
}),
],
);
}
/// Build a single popular track item with dynamic download status
Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) {
// Watch download queue for this track's status
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return InkWell(
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
// Rank number
SizedBox(
width: 24,
child: Text(
'$rank',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 12),
// Album art
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: track.coverUrl != null
? CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 96,
placeholder: (context, url) => Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
),
)
: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
),
),
const SizedBox(width: 12),
// Track info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (track.albumName.isNotEmpty)
Text(
track.albumName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Download button with status
_buildPopularDownloadButton(
track: track,
colorScheme: colorScheme,
isQueued: isQueued,
isDownloading: isDownloading,
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
progress: progress,
),
],
),
),
);
}
/// Handle tap on popular track item
void _handlePopularTrackTap(Track track, {required bool isQueued, required bool isInHistory}) async {
if (isQueued) return;
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
);
}
return;
} else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
}
}
}
_downloadTrack(track);
}
/// Build download button with status indicator for popular tracks
Widget _buildPopularDownloadButton({
required Track track,
required ColorScheme colorScheme,
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required double progress,
}) {
const double size = 40.0;
const double iconSize = 20.0;
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize),
),
);
} else if (isFinalizing) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.tertiary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 14),
],
),
);
} else if (isDownloading) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress > 0 ? progress : null,
strokeWidth: 2.5,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: colorScheme.primary),
),
],
),
);
} else if (isQueued) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize),
);
} else {
return GestureDetector(
onTap: () => _downloadTrack(track),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize),
),
);
}
}
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
duration: const Duration(seconds: 2),
),
);
}
@@ -271,24 +702,26 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Icon(Icons.album, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
],
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Text(
'$title (${albums.length})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
SizedBox(
height: 210,
height: 220,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return KeyedSubtree(key: ValueKey(album.id), child: _buildAlbumCard(album, colorScheme));
return KeyedSubtree(
key: ValueKey(album.id),
child: _buildAlbumCard(album, colorScheme),
);
},
),
),
@@ -301,62 +734,90 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
onTap: () => _navigateToAlbum(album),
child: Container(
width: 140,
margin: const EdgeInsets.symmetric(horizontal: 6),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: album.coverUrl != null
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
),
const SizedBox(height: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
const Spacer(),
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate}${album.totalTracks} tracks'
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Album cover
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
width: 140,
height: 140,
fit: BoxFit.cover,
memCacheWidth: 280,
placeholder: (context, url) => Container(
width: 140,
height: 140,
color: colorScheme.surfaceContainerHighest,
),
],
),
),
],
errorWidget: (context, url, error) => Container(
width: 140,
height: 140,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
),
)
: Container(
width: 140,
height: 140,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
),
),
),
const SizedBox(height: 8),
// Album name
Text(
album.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// Year and track count
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
void _navigateToAlbum(ArtistAlbum album) {
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
ref.read(settingsProvider.notifier).setHasSearchedBefore();
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
// tracks: null - will be fetched in AlbumScreen
),
));
if (album.providerId != null && album.providerId!.isNotEmpty) {
Navigator.push(context, MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen(
extensionId: album.providerId!,
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
),
));
} else {
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
),
));
}
}
/// Build error widget with special handling for rate limit (429)
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
@@ -366,7 +827,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return Card(
elevation: 0,
color: colorScheme.errorContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
@@ -378,7 +839,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
context.l10n.errorRateLimited,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
@@ -386,7 +847,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment and try again.',
context.l10n.errorRateLimitedMessage,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
@@ -401,11 +862,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
+18 -15
View File
@@ -4,6 +4,8 @@ import 'package:flutter/services.dart';
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/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -83,19 +85,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Selected'),
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
child: Text(context.l10n.dialogDelete),
),
],
),
@@ -124,7 +126,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
);
}
}
@@ -132,11 +134,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Future<void> _openFile(String filePath) async {
try {
await OpenFilex.open(filePath);
final mimeType = audioMimeTypeForPath(filePath);
await OpenFilex.open(filePath, type: mimeType);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
);
}
}
@@ -321,7 +324,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} downloaded', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.downloadedAlbumDownloadedCount(tracks.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -374,13 +377,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
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: const Text('Select'),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
),
],
@@ -521,11 +524,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$selectedCount selected',
context.l10n.downloadedAlbumSelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? 'All tracks selected' : 'Tap tracks to select',
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
@@ -540,7 +543,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? 'Deselect' : 'Select All'),
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
),
],
@@ -553,8 +556,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
: 'Select tracks to delete',
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
+93 -9
View File
@@ -6,6 +6,8 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@@ -267,6 +269,23 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
final track = ref.watch(trackProvider).tracks[index];
final isCollection = track.isCollection;
// Determine subtitle text based on item type
String subtitleText;
if (isCollection) {
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
final capitalizedType = typeLabel.isNotEmpty
? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}'
: 'Album';
final year = track.releaseDate != null && track.releaseDate!.length >= 4
? track.releaseDate!.substring(0, 4)
: '';
subtitleText = '$capitalizedType${track.artistName}${year.isNotEmpty ? '$year' : ''}';
} else {
subtitleText = track.artistName;
}
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
@@ -285,22 +304,87 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
child: Icon(
isCollection ? Icons.album : Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
track.artistName,
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: Text(
_formatDuration(track.duration),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => _downloadTrack(index),
trailing: isCollection
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
: Text(
_formatDuration(track.duration),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index),
);
}
Future<void> _openCollection(Track track) async {
// Get the extension ID from the track source
final extensionId = track.source;
if (extensionId == null) return;
// Fetch album/playlist tracks using the extension
try {
if (track.isAlbumItem) {
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
if (albumData != null && mounted) {
final trackList = albumData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
albumName: albumData['name'] as String? ?? track.name,
coverUrl: albumData['cover_url'] as String? ?? track.coverUrl,
);
}
} else if (track.isPlaylistItem) {
final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id);
if (playlistData != null && mounted) {
final trackList = playlistData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
playlistName: playlistData['name'] as String? ?? track.name,
coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl,
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load: $e')),
);
}
}
}
Track _parseExtensionTrack(Map<String, dynamic> data, String source) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? '').toString(),
albumName: (data['album_name'] ?? '').toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
duration: (durationMs / 1000).round(),
releaseDate: data['release_date']?.toString(),
source: source,
);
}
+1188 -313
View File
File diff suppressed because it is too large Load Diff
+90 -37
View File
@@ -2,10 +2,13 @@ import 'dart:async';
import 'package:flutter/material.dart';
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/settings_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/screens/store_tab.dart';
import 'package:spotiflac_android/screens/queue_tab.dart';
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
@@ -76,7 +79,7 @@ class _MainShellState extends ConsumerState<MainShell> {
// Show snackbar
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Loading shared link...')),
SnackBar(content: Text(context.l10n.loadingSharedLink)),
);
}
}
@@ -121,6 +124,9 @@ class _MainShellState extends ConsumerState<MainShell> {
void _onPageChanged(int index) {
if (_currentIndex != index) {
setState(() => _currentIndex = index);
// Unfocus any text field when switching tabs to prevent keyboard from appearing
// Use primaryFocus for more aggressive unfocus that works with keep-alive widgets
FocusManager.instance.primaryFocus?.unfocus();
}
}
@@ -131,7 +137,15 @@ class _MainShellState extends ConsumerState<MainShell> {
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
if (isKeyboardVisible) {
FocusScope.of(context).unfocus();
FocusManager.instance.primaryFocus?.unfocus();
return;
}
// If on Home tab and showing recent access mode, exit it
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
// Also unfocus search bar when exiting recent access mode
FocusManager.instance.primaryFocus?.unfocus();
return;
}
@@ -159,9 +173,9 @@ class _MainShellState extends ConsumerState<MainShell> {
} else {
_lastBackPress = now;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Press back again to exit'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.pressBackAgainToExit),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
@@ -172,6 +186,8 @@ class _MainShellState extends ConsumerState<MainShell> {
Widget build(BuildContext context) {
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));
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
@@ -183,8 +199,73 @@ class _MainShellState extends ConsumerState<MainShell> {
!trackState.hasSearchText &&
!trackState.hasContent &&
!trackState.isLoading &&
!trackState.isShowingRecentAccess &&
!isKeyboardVisible;
// Build tabs and destinations based on settings
final tabs = <Widget>[
const HomeTab(),
QueueTab(
parentPageController: _pageController,
parentPageIndex: 1,
nextPageIndex: showStore ? 2 : 3,
),
if (showStore) const StoreTab(),
const SettingsTab(),
];
final l10n = context.l10n;
final destinations = <NavigationDestination>[
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: l10n.navHome,
),
NavigationDestination(
icon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.history_outlined),
),
selectedIcon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.history),
),
label: l10n.navHistory,
),
if (showStore)
NavigationDestination(
icon: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store_outlined),
),
selectedIcon: 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),
label: l10n.navSettings,
),
];
// Clamp current index if tabs changed
final maxIndex = tabs.length - 1;
if (_currentIndex > maxIndex) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() => _currentIndex = maxIndex);
_pageController.jumpToPage(maxIndex);
}
});
}
return PopScope(
canPop: canPop,
onPopInvokedWithResult: (didPop, result) async {
@@ -201,45 +282,17 @@ class _MainShellState extends ConsumerState<MainShell> {
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const BouncingScrollPhysics(),
children: const [
HomeTab(),
QueueTab(),
SettingsTab(),
],
physics: const ClampingScrollPhysics(),
children: tabs,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
selectedIndex: _currentIndex.clamp(0, maxIndex),
onDestinationSelected: _onNavTap,
animationDuration: const Duration(milliseconds: 200),
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),
destinations: [
const NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.history_outlined),
),
selectedIcon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.history),
),
label: 'History',
),
const NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
destinations: destinations,
),
),
);
+27 -221
View File
@@ -2,10 +2,12 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
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/widgets/download_service_picker.dart';
/// Playlist detail screen with Material Expressive 3 design
class PlaylistScreen extends ConsumerWidget {
@@ -113,7 +115,7 @@ class PlaylistScreen extends ConsumerWidget {
children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -121,7 +123,7 @@ class PlaylistScreen extends ConsumerWidget {
FilledButton.icon(
onPressed: () => _downloadAll(context, ref),
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
@@ -140,7 +142,7 @@ class PlaylistScreen extends ConsumerWidget {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
],
),
),
@@ -168,13 +170,19 @@ class PlaylistScreen extends ConsumerWidget {
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, ref, (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
DownloadServicePicker.show(
context,
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
}
}
@@ -182,222 +190,20 @@ class PlaylistScreen extends ConsumerWidget {
if (tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, ref, (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}, trackName: '${tracks.length} tracks', artistName: playlistName);
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
artistName: playlistName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
}
}
void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
String selectedService = settings.defaultService;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
// Service selector
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
const SizedBox(width: 8),
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
const SizedBox(width: 8),
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
],
),
),
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
const SizedBox(height: 16),
],
),
),
),
),
);
}
}
class _QualityOption extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final VoidCallback onTap;
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
onTap: onTap,
);
}
}
class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
final String? coverUrl;
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
@override
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
}
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
bool _expanded = false;
bool _isOverflowing = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
child: Column(
children: [
const SizedBox(height: 8),
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: widget.coverUrl != null
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
),
const SizedBox(width: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
final titleOverflows = titlePainter.didExceedMaxLines;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _isOverflowing != titleOverflows) {
setState(() => _isOverflowing = titleOverflows);
}
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.trackName,
style: titleStyle,
maxLines: _expanded ? 10 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
if (widget.artistName != null) ...[
const SizedBox(height: 2),
Text(
widget.artistName!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: _expanded ? 3 : 1,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
],
],
);
},
),
),
if (_isOverflowing || _expanded)
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
],
),
),
],
),
),
);
}
}
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
@@ -459,7 +265,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
}
return;
} else {
+16 -15
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -14,19 +15,19 @@ class QueueScreen extends ConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text('Download Queue'),
title: Text(context.l10n.queueTitle),
actions: [
if (queueState.items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
tooltip: 'Clear completed',
tooltip: context.l10n.queueClearCompleted,
),
if (queueState.items.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () => _showClearAllDialog(context, ref),
tooltip: 'Clear all',
tooltip: context.l10n.queueClearAll,
),
],
),
@@ -51,14 +52,14 @@ class QueueScreen extends ConsumerWidget {
),
const SizedBox(height: 16),
Text(
'No downloads in queue',
context.l10n.queueEmpty,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Add tracks from the home screen',
context.l10n.queueEmptySubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
@@ -177,7 +178,7 @@ class QueueScreen extends ConsumerWidget {
children: [
Icon(Icons.error, color: colorScheme.error),
const SizedBox(width: 8),
const Text('Download Failed'),
Text(context.l10n.queueDownloadFailed),
],
),
content: SingleChildScrollView(
@@ -185,10 +186,10 @@ class QueueScreen extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('Artist: ${item.track.artistName}'),
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
const SizedBox(height: 16),
const Text('Error:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(context.l10n.queueErrorLabel, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
@@ -197,7 +198,7 @@ class QueueScreen extends ConsumerWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.error ?? 'Unknown error',
item.error ?? context.l10n.queueUnknownError,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
@@ -211,7 +212,7 @@ class QueueScreen extends ConsumerWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.dialogClose),
),
],
),
@@ -223,19 +224,19 @@ class QueueScreen extends ConsumerWidget {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear All'),
content: const Text('Are you sure you want to clear all downloads?'),
title: Text(context.l10n.queueClearAll),
content: Text(context.l10n.queueClearAllMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearAll();
Navigator.pop(context);
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
),
],
),
+923 -426
View File
File diff suppressed because it is too large Load Diff

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