Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be9444c76b | |||
| cedb32904e | |||
| e73f932083 | |||
| 4645d3ac8b | |||
| 1cdf8b7f23 | |||
| 1e18f53e6a | |||
| fc8cfb05d0 | |||
| fc0c0571fe | |||
| e6ca29e199 | |||
| 7413a8a698 | |||
| 205032e094 | |||
| 9c6f438e22 | |||
| 4f2587554a | |||
| 369fdd84bf | |||
| 5c3b668e92 | |||
| 141db45051 | |||
| 8f9bc8f058 | |||
| be372604fe | |||
| 6c25fc6a8d | |||
| 2eef021587 | |||
| 9eac6e6e56 | |||
| e5c310f455 | |||
| d8f73dfa56 | |||
| f128d0caf0 | |||
| aa499ceba2 | |||
| 01306afc2d | |||
| 9a3cd0273b | |||
| ac25683f33 | |||
| 624b2112d8 | |||
| 8bd34dc87e | |||
| 948779bcfc | |||
| a74b3a19f7 | |||
| 931d9fbf61 | |||
| a8c76004db | |||
| 0df4596f79 | |||
| cf549df049 | |||
| bd3783154b | |||
| 6919408905 | |||
| f4c08a5981 | |||
| 7fff55da96 | |||
| 3c4dbd1a80 | |||
| f26af38c1e | |||
| 7c6705c75c | |||
| b193bc0b8f | |||
| 1a90887465 | |||
| 82440affac | |||
| 6d2f75c5dc | |||
| 18bc079632 | |||
| 4091a9c499 | |||
| 9346f2d149 | |||
| 8ab52959e8 | |||
| bad95e99c8 | |||
| dbd7fd70be | |||
| 125d070cfe | |||
| 15acf181d1 | |||
| e049f9b868 | |||
| 6a886c5276 | |||
| 1ec190bfe7 | |||
| 7ca032b3f5 | |||
| 13b917d1a0 | |||
| 961072e2ac | |||
| 8a7815268b | |||
| c7e1ffd926 | |||
| 729ab01a5f | |||
| 0a16be4395 | |||
| 47cdb5564a | |||
| f7d5a24d17 | |||
| 8daff4d0a4 | |||
| a38d66fd41 | |||
| 0cab01780d | |||
| 4afc14dee8 | |||
| 00753ffe86 | |||
| 523b1edc44 | |||
| 4966a84614 | |||
| 9247a775fa | |||
| b185b51b31 | |||
| d98960d053 | |||
| d417743654 | |||
| c4bea124fb | |||
| c37410b5de | |||
| b90c94125c | |||
| efbf5d4c5b | |||
| 35532b0c73 | |||
| 4c09b988e4 | |||
| c673581c32 | |||
| bcd718b178 | |||
| 2b9357cb6d | |||
| 26d84041c7 | |||
| 93b4047143 | |||
| a6d488696b | |||
| 3dbd131e49 | |||
| 57cb575483 | |||
| 24ef66be4c | |||
| d07a49f605 | |||
| 4eba28db7a | |||
| b73a3f8912 | |||
| 9f47f2ce85 | |||
| f2aca734a3 | |||
| 09cb637a86 |
@@ -16,7 +16,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: I have searched existing issues and this bug hasn't been reported yet
|
- label: I have searched existing issues and this bug hasn't been reported yet
|
||||||
required: true
|
required: true
|
||||||
- label: I am using the latest version of SpotiFLAC
|
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ contact_links:
|
|||||||
- name: README
|
- name: README
|
||||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||||
about: Check the README for setup instructions and FAQ
|
about: Check the README for setup instructions and FAQ
|
||||||
|
- name: Extension Development Guide
|
||||||
|
url: https://zarz.moe/docs
|
||||||
|
about: Documentation for building SpotiFLAC extensions
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
||||||
required: true
|
required: true
|
||||||
- label: I am using the latest version of SpotiFLAC
|
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
name: Extension API Feature Request
|
||||||
|
description: Request new API features or capabilities for extension development
|
||||||
|
title: "[Extension API]: "
|
||||||
|
labels: ["enhancement", "extension-api"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for helping improve the SpotiFLAC Extension API!
|
||||||
|
This form is for extension developers who need new features or capabilities that don't exist yet.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extension_goal
|
||||||
|
attributes:
|
||||||
|
label: What are you trying to build?
|
||||||
|
description: Describe the extension or feature you're developing
|
||||||
|
placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: current_limitation
|
||||||
|
attributes:
|
||||||
|
label: Current API Limitation
|
||||||
|
description: What's missing or limiting in the current extension API?
|
||||||
|
placeholder: |
|
||||||
|
The current API doesn't support:
|
||||||
|
- [missing feature 1]
|
||||||
|
- [missing feature 2]
|
||||||
|
|
||||||
|
This prevents me from...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposed_api
|
||||||
|
attributes:
|
||||||
|
label: Proposed API / Feature
|
||||||
|
description: Describe the API or feature you'd like to see added
|
||||||
|
placeholder: |
|
||||||
|
I would like to have:
|
||||||
|
- A new function `api.newFeature()` that does X
|
||||||
|
- A new manifest field `newOption` that enables Y
|
||||||
|
- Access to Z capability...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: use_case
|
||||||
|
attributes:
|
||||||
|
label: Use Case Example
|
||||||
|
description: Provide a code example of how you would use this feature
|
||||||
|
placeholder: |
|
||||||
|
```javascript
|
||||||
|
// Example usage in extension code
|
||||||
|
function download(request, progressCallback) {
|
||||||
|
const result = api.proposedFeature(params);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: api_category
|
||||||
|
attributes:
|
||||||
|
label: API Category
|
||||||
|
description: What category does this feature fall under?
|
||||||
|
options:
|
||||||
|
- HTTP/Network API
|
||||||
|
- File System API
|
||||||
|
- Storage API
|
||||||
|
- FFmpeg/Audio Processing
|
||||||
|
- Manifest Options
|
||||||
|
- Runtime Functions
|
||||||
|
- UI Integration
|
||||||
|
- Authentication
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: How critical is this for your extension?
|
||||||
|
options:
|
||||||
|
- Blocker - Cannot build my extension without this
|
||||||
|
- High - Major functionality depends on this
|
||||||
|
- Medium - Would significantly improve my extension
|
||||||
|
- Low - Nice to have
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: workaround
|
||||||
|
attributes:
|
||||||
|
label: Current Workaround
|
||||||
|
description: Are you using any workaround currently? If so, describe it.
|
||||||
|
placeholder: "Currently I'm working around this by..."
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, links to similar APIs, or examples from other platforms
|
||||||
|
placeholder: "Similar feature in other platforms: ..."
|
||||||
@@ -345,6 +345,8 @@ jobs:
|
|||||||
CHANGELOG="See CHANGELOG.md for details."
|
CHANGELOG="See CHANGELOG.md for details."
|
||||||
else
|
else
|
||||||
echo "Found changelog content"
|
echo "Found changelog content"
|
||||||
|
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
|
||||||
|
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Save to file for multiline support
|
# Save to file for multiline support
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ Thumbs.db
|
|||||||
# Reference folder (development only)
|
# Reference folder (development only)
|
||||||
referensi/
|
referensi/
|
||||||
|
|
||||||
|
# Documentation (development only, published separately)
|
||||||
|
docs/
|
||||||
|
|
||||||
# Old spotiflac_android folder (moved to root)
|
# Old spotiflac_android folder (moved to root)
|
||||||
spotiflac_android/
|
spotiflac_android/
|
||||||
|
|
||||||
@@ -50,3 +53,20 @@ ios/.symlinks/
|
|||||||
ios/Flutter/Flutter.framework/
|
ios/Flutter/Flutter.framework/
|
||||||
ios/Flutter/Flutter.podspec
|
ios/Flutter/Flutter.podspec
|
||||||
android/app/libs/gobackend-sources.jar
|
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/
|
||||||
|
|||||||
@@ -1,5 +1,769 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- **Multi-Select Batch Delete**: Long-press tracks in History to enter selection mode
|
||||||
|
- Select multiple tracks at once
|
||||||
|
- "Select All" and "Delete Selected" actions
|
||||||
|
- Modern Material 3 bottom action bar (slides up from bottom)
|
||||||
|
- Works in both grid and list view modes
|
||||||
|
- **History Filter Tabs**: Filter history by All/Albums/Singles
|
||||||
|
- Album = tracks where album has >1 track in history
|
||||||
|
- Single = tracks where album has only 1 track in history
|
||||||
|
- Filter chips show counts for each category
|
||||||
|
- **Album Grouping View**: When "Albums" filter is selected, tracks are grouped by album
|
||||||
|
- Album cards displayed in 2-column grid with cover art and track count badge
|
||||||
|
- Tap album to open dedicated album detail screen
|
||||||
|
- Album detail shows all downloaded tracks from that album
|
||||||
|
- Multi-select delete support within album view
|
||||||
|
- Auto-navigates back when album has <2 tracks remaining
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.7] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **CSV Import Metadata Enrichment**: Tracks imported from CSV now automatically fetch metadata from Deezer
|
||||||
|
- Cover art, duration, track/disc number fetched via ISRC lookup
|
||||||
|
- Fallback to text search (artist + track name) when ISRC not found in Deezer
|
||||||
|
- Progress dialog shows enrichment status during import
|
||||||
|
- Ensures downloaded files have proper cover art and metadata
|
||||||
|
- **Deezer Metadata Support**: Enhanced metadata viewer for Deezer tracks
|
||||||
|
- "Open in Deezer" button for Deezer-sourced tracks (opens app or web)
|
||||||
|
- Displays "Deezer ID" instead of "Spotify ID" when applicable
|
||||||
|
- **Smart Tag Injection**: Filename format editor intelligently handles separators
|
||||||
|
- Auto-detects if " - " is needed between tags
|
||||||
|
- Prevents double separators or missing spaces
|
||||||
|
- **Dynamic Source Info**: Search source selector now shows helpful context
|
||||||
|
- "No login required" for Deezer
|
||||||
|
- "Requires credentials" for Spotify
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **UI Modernization**: Major UI consistency updates across the app
|
||||||
|
- **Unified App Bars**: Home, History, and Settings now share identical behavior
|
||||||
|
- Lowered expanded header for easier one-handed reachability
|
||||||
|
- Dynamic title text scaling (20px to 34px)
|
||||||
|
- **Appearance Settings**: Completely redesigned appearance page
|
||||||
|
- New "Theme Preview" card showing visualizing current theme
|
||||||
|
- Modern color palette picker replacing old color dots
|
||||||
|
- Clean, grouped layout
|
||||||
|
- "AMOLED Dark" switch is now hidden when using Light Mode
|
||||||
|
- **App Logo**: Refined logo style on Home and About screens
|
||||||
|
- Inverted colors: Filled primary color circle with on-color icon
|
||||||
|
- Removed padding for a cleaner, bolder look
|
||||||
|
- **Material 3 Switches**: Added checkmark icon to active switches
|
||||||
|
- **UI Modernization (Global)**: Complete design refresh for a cleaner, modern look
|
||||||
|
- **Rounded Corners**: Standardized 16px radius for all cards, buttons, and input fields
|
||||||
|
- **Transparent Elements**: Applied subtle transparency to input fields and containers using `surfaceContainerHighest`
|
||||||
|
- **Consistent Buttons**: Unified button styling across the app (pill shape, 16px radius)
|
||||||
|
- **Options Settings Redesign**: improved layout and usability
|
||||||
|
- **Search Source Priority**: Moved "Search Source" section to the very top for quick access
|
||||||
|
- **Compact Source Selector**: Redesigned provider toggle (Deezer/Spotify) to be compact and consistent
|
||||||
|
- **Credentials Workflow**: Reorganized Custom Credentials settings; toggle now auto-prompts if credentials missing
|
||||||
|
- **Modern Credentials Dialog**: Totally redesigned input dialog for Spotify Client ID/Secret
|
||||||
|
- **Filename Format Editor 2.0**:
|
||||||
|
- **Modern Sheet UI**: Replaced legacy dialog with a clean, full-width bottom sheet
|
||||||
|
- **Tag Chips**: Added clickable chips ({artist}, {title}) for one-tap insertion
|
||||||
|
- **Smart Formatting**: Automatically injects separators (" - ") when adding tags for faster editing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **CSV Import Missing Cover Art**: Fixed tracks from CSV having no cover art in download history
|
||||||
|
- Cover URL now properly fetched from Deezer during enrichment
|
||||||
|
- Falls back to text search when ISRC lookup fails
|
||||||
|
- **CSV Import Missing Duration**: Fixed duration showing 0:00 for CSV-imported tracks
|
||||||
|
- Duration now fetched from Deezer metadata during enrichment
|
||||||
|
- **Disc Number Not Displayed**: Fixed disc number not showing in track metadata screen
|
||||||
|
- Changed condition from `discNumber > 0` to `discNumber > 0`
|
||||||
|
- Now displays disc 1 instead of hiding it
|
||||||
|
- **Download History Using Wrong Track Data**: Fixed history using original CSV data instead of enriched data
|
||||||
|
- Now uses `trackToDownload` (enriched) instead of `item.track` (original)
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Updated `lib/services/csv_import_service.dart`:
|
||||||
|
- Added `_enrichTracksMetadata()` with ISRC lookup + text search fallback
|
||||||
|
- Added progress callback for UI feedback
|
||||||
|
- Updated `lib/screens/home_tab.dart`:
|
||||||
|
- Added progress dialog during CSV enrichment
|
||||||
|
- Updated `lib/providers/download_queue_provider.dart`:
|
||||||
|
- Uses enriched track data for download history
|
||||||
|
- Updated `lib/screens/track_metadata_screen.dart`:
|
||||||
|
- Show disc number when > 0 (was > 1)
|
||||||
|
- Updated `go_backend/metadata.go`:
|
||||||
|
- Added `TotalSamples` to `AudioQuality` struct for duration calculation
|
||||||
|
- Updated `go_backend/exports.go`:
|
||||||
|
- `ReadFileMetadata` now returns duration calculated from FLAC stream info
|
||||||
|
- Updated `AppTheme` with new `InputDecorationTheme` and `ButtonTheme` definitions
|
||||||
|
- Refactored `DownloadSettingsPage` to use new `_showFormatEditor` with cursor-aware capabilities
|
||||||
|
- Optimized various dialogs to use `showModalBottomSheet` with `isScrollControlled` for better keyboard handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.6] - 2026-01-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Release Mode Logging**: Flutter app logs now properly captured in release builds
|
||||||
|
- Previously only Go backend logs appeared when "Detailed Logging" was enabled
|
||||||
|
- Now both Flutter and Go logs are captured in release mode
|
||||||
|
- Bypasses Logger package which filters logs in release mode
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Detailed Deezer Search Logging**: Better debugging for search issues
|
||||||
|
- Logs API URLs, response counts, and errors
|
||||||
|
- Helps diagnose geo-restriction and API issues
|
||||||
|
- Detects Deezer API error responses
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Home Screen Logo**: Replaced music note icon with app logo
|
||||||
|
- Uses `assets/images/logo.png`
|
||||||
|
- Rounded corners (24px radius)
|
||||||
|
- Fallback to music note icon if logo fails to load
|
||||||
|
- **About Page Logo**: Removed shadow/border from logo
|
||||||
|
- Cleaner appearance without background container
|
||||||
|
- **About Page Icon Alignment**: Icons now aligned with contributor avatars
|
||||||
|
- DoubleDouble and DAB Music icons use 40x40 area
|
||||||
|
- Text now properly aligned with contributor items
|
||||||
|
|
||||||
## [2.2.5] - 2026-01-10
|
## [2.2.5] - 2026-01-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/cd205e22783a179aab80a2f0cc4445c84e59615a08c11d6e722ab4692c26ac37)
|
[](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
|
||||||
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
<div align="center">
|
<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" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Metadata Source
|
## Search Source
|
||||||
|
|
||||||
SpotiFLAC supports two metadata sources for searching tracks:
|
SpotiFLAC supports two search sources:
|
||||||
|
|
||||||
| Source | Pros | Cons |
|
| Source | Setup |
|
||||||
|--------|------|------|
|
|--------|-------|
|
||||||
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
|
| **Deezer** (Default) | No setup required |
|
||||||
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
|
| **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
|
## Extensions
|
||||||
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)
|
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.
|
||||||
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**
|
### Installing Extensions
|
||||||
4. Enter your Client ID and Secret
|
1. Go to **Store** tab in the app
|
||||||
5. Change **Search Source** to Spotify
|
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
|
## Other project
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
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).
|
||||||
|
|
||||||
[](https://ko-fi.com/zarzet)
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
## Disclaimer
|
## 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.
|
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||||
|
|
||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"cancelDownload" -> {
|
||||||
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.cancelDownload(itemId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
"setDownloadDirectory" -> {
|
"setDownloadDirectory" -> {
|
||||||
val path = call.argument<String>("path") ?: ""
|
val path = call.argument<String>("path") ?: ""
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -218,6 +225,12 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"hasSpotifyCredentials" -> {
|
||||||
|
val hasCredentials = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.checkSpotifyCredentials()
|
||||||
|
}
|
||||||
|
result.success(hasCredentials)
|
||||||
|
}
|
||||||
"preWarmTrackCache" -> {
|
"preWarmTrackCache" -> {
|
||||||
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -317,6 +330,337 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
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()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
files:
|
||||||
|
- source: /lib/l10n/arb/app_en.arb
|
||||||
|
translation: /lib/l10n/arb/app_%locale_with_underscore%.arb
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -173,7 +175,7 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
|||||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
||||||
// This uses submit → poll → download mechanism
|
// This uses submit → poll → download mechanism
|
||||||
// Internal function - not exported to gomobile
|
// 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
|
var lastError error
|
||||||
|
|
||||||
for _, region := range a.regions {
|
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
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
// Initialize item progress (required for all downloads)
|
// Initialize item progress (required for all downloads)
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
|
ctx = initDownloadCancel(itemID)
|
||||||
|
defer clearDownloadCancel(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -361,6 +371,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
|
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -400,6 +413,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
// Check for any errors
|
// Check for any errors
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if flushErr != nil {
|
if flushErr != nil {
|
||||||
@@ -527,6 +543,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return AmazonDownloadResult{}, ErrDownloadCancelled
|
||||||
|
}
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -9,10 +9,20 @@ import (
|
|||||||
|
|
||||||
// Spotify image size codes (same as PC version)
|
// Spotify image size codes (same as PC version)
|
||||||
const (
|
const (
|
||||||
spotifySize640 = "ab67616d0000b273" // 640x640
|
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
|
||||||
|
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
|
||||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
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)
|
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
|
||||||
// This avoids file permission issues on Android
|
// This avoids file permission issues on Android
|
||||||
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
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")
|
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
|
// First upgrade small (300) to medium (640) - always do this
|
||||||
downloadURL := coverURL
|
downloadURL := convertSmallToMedium(coverURL)
|
||||||
|
if downloadURL != coverURL {
|
||||||
|
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then upgrade to max quality if requested
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
downloadURL = upgradeToMaxQuality(coverURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
if downloadURL != coverURL {
|
if maxURL != downloadURL {
|
||||||
fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", 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)
|
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||||
|
|
||||||
// Create request with User-Agent (required by Spotify CDN)
|
// 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)
|
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
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
|
// 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 {
|
func upgradeToMaxQuality(coverURL string) string {
|
||||||
// Spotify image URLs can be upgraded by changing the size parameter
|
// Spotify image URLs can be upgraded by changing the size parameter
|
||||||
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
||||||
@@ -67,21 +100,7 @@ func upgradeToMaxQuality(coverURL string) string {
|
|||||||
// ab67616d000082c1 = Max resolution (~2000x2000)
|
// ab67616d000082c1 = Max resolution (~2000x2000)
|
||||||
|
|
||||||
if strings.Contains(coverURL, spotifySize640) {
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
// Try max resolution first
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
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 coverURL
|
return coverURL
|
||||||
@@ -93,9 +112,12 @@ func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always upgrade small to medium first
|
||||||
|
result := convertSmallToMedium(imageURL)
|
||||||
|
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
return upgradeToMaxQuality(imageURL)
|
result = upgradeToMaxQuality(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
return imageURL
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,27 +58,27 @@ func GetDeezerClient() *DeezerClient {
|
|||||||
|
|
||||||
// Deezer API response types
|
// Deezer API response types
|
||||||
type deezerTrack struct {
|
type deezerTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Duration int `json:"duration"` // in seconds
|
Duration int `json:"duration"` // in seconds
|
||||||
TrackPosition int `json:"track_position"`
|
TrackPosition int `json:"track_position"`
|
||||||
DiskNumber int `json:"disk_number"`
|
DiskNumber int `json:"disk_number"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||||
Artist deezerArtist `json:"artist"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Album deezerAlbumSimple `json:"album"`
|
Album deezerAlbumSimple `json:"album"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type deezerArtist struct {
|
type deezerArtist struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Picture string `json:"picture"`
|
Picture string `json:"picture"`
|
||||||
PictureMedium string `json:"picture_medium"`
|
PictureMedium string `json:"picture_medium"`
|
||||||
PictureBig string `json:"picture_big"`
|
PictureBig string `json:"picture_big"`
|
||||||
PictureXL string `json:"picture_xl"`
|
PictureXL string `json:"picture_xl"`
|
||||||
NbFan int `json:"nb_fan"`
|
NbFan int `json:"nb_fan"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type deezerAlbumSimple struct {
|
type deezerAlbumSimple struct {
|
||||||
@@ -89,10 +89,9 @@ type deezerAlbumSimple struct {
|
|||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
ReleaseDate string `json:"release_date"` // 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 {
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
artistName := track.Artist.Name
|
artistName := track.Artist.Name
|
||||||
if len(track.Contributors) > 0 {
|
if len(track.Contributors) > 0 {
|
||||||
@@ -137,17 +136,18 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type deezerAlbumFull struct {
|
type deezerAlbumFull struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
Artist deezerArtist `json:"artist"`
|
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||||
|
Artist deezerArtist `json:"artist"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
@@ -164,17 +164,17 @@ type deezerArtistFull struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type deezerPlaylistFull struct {
|
type deezerPlaylistFull struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Picture string `json:"picture"`
|
Picture string `json:"picture"`
|
||||||
PictureMedium string `json:"picture_medium"`
|
PictureMedium string `json:"picture_medium"`
|
||||||
PictureBig string `json:"picture_big"`
|
PictureBig string `json:"picture_big"`
|
||||||
PictureXL string `json:"picture_xl"`
|
PictureXL string `json:"picture_xl"`
|
||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
Creator struct {
|
Creator struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"creator"`
|
} `json:"creator"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
@@ -182,11 +182,14 @@ type deezerPlaylistFull struct {
|
|||||||
// SearchAll searches for tracks and artists on Deezer
|
// SearchAll searches for tracks and artists on Deezer
|
||||||
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
||||||
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
|
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
GoLog("[Deezer] SearchAll: returning cached result\n")
|
||||||
return entry.data.(*SearchAllResult), nil
|
return entry.data.(*SearchAllResult), nil
|
||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -198,13 +201,28 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
|
|
||||||
// Search tracks - NO ISRC fetch for performance
|
// Search tracks - NO ISRC fetch for performance
|
||||||
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||||
|
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
||||||
|
|
||||||
var trackResp struct {
|
var trackResp struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||||
|
GoLog("[Deezer] Track search failed: %v\n", err)
|
||||||
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if trackResp.Error != nil {
|
||||||
|
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
||||||
|
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||||
|
|
||||||
for _, track := range trackResp.Data {
|
for _, track := range trackResp.Data {
|
||||||
// Convert directly without fetching ISRC - much faster
|
// Convert directly without fetching ISRC - much faster
|
||||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||||
@@ -212,21 +230,37 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
|
|
||||||
// Search artists
|
// Search artists
|
||||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||||
|
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||||
|
|
||||||
var artistResp struct {
|
var artistResp struct {
|
||||||
Data []deezerArtist `json:"data"`
|
Data []deezerArtist `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||||
for _, artist := range artistResp.Data {
|
if artistResp.Error != nil {
|
||||||
result.Artists = append(result.Artists, SearchArtistResult{
|
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
} else {
|
||||||
Name: artist.Name,
|
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||||
Images: c.getBestArtistImage(artist),
|
for _, artist := range artistResp.Data {
|
||||||
Followers: artist.NbFan,
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
Popularity: 0,
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
})
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImage(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Artist search failed: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
||||||
|
|
||||||
// Cache result
|
// Cache result
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
@@ -291,6 +325,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(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 {
|
for _, track := range album.Tracks.Data {
|
||||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
isrc := isrcMap[trackIDStr]
|
isrc := isrcMap[trackIDStr]
|
||||||
@@ -310,6 +350,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
AlbumType: albumType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,8 +644,6 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
|||||||
return fullTrack.ISRC, nil
|
return fullTrack.ISRC, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
||||||
if artist.PictureXL != "" {
|
if artist.PictureXL != "" {
|
||||||
return artist.PictureXL
|
return artist.PictureXL
|
||||||
|
|||||||
@@ -103,6 +103,18 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
|||||||
return path, exists
|
return path, exists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// remove deletes an ISRC entry from the index (internal use)
|
||||||
|
func (idx *ISRCIndex) remove(isrc string) {
|
||||||
|
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)
|
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
||||||
// Returns filepath if found, empty string if not found
|
// Returns filepath if found, empty string if not found
|
||||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||||
@@ -138,7 +150,18 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
|||||||
|
|
||||||
// Use index for fast lookup
|
// Use index for fast lookup
|
||||||
idx := GetISRCIndex(outputDir)
|
idx := GetISRCIndex(outputDir)
|
||||||
return idx.lookup(isrc)
|
filePath, exists := idx.lookup(isrc)
|
||||||
|
if !exists {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !CheckFileExists(filePath) {
|
||||||
|
// Stale index entry; remove it and return not found.
|
||||||
|
idx.remove(isrc)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, ®istry); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.cache = ®istry
|
||||||
|
s.cacheTime = time.Now()
|
||||||
|
s.saveDiskCache()
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions))
|
||||||
|
return ®istry, 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,14 +5,19 @@ go 1.24.0
|
|||||||
toolchain go1.24.5
|
toolchain go1.24.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||||
github.com/go-flac/flacpicture v0.3.0
|
github.com/go-flac/flacpicture v0.3.0
|
||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacvorbis v0.2.0
|
||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac v1.0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
||||||
golang.org/x/mod v0.31.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/text v0.3.8 // indirect
|
||||||
golang.org/x/tools v0.40.0 // indirect
|
golang.org/x/tools v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
||||||
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
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 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
||||||
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
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 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
||||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/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 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
||||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
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 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
golang.org/x/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 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
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=
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ import (
|
|||||||
func getRandomUserAgent() string {
|
func getRandomUserAgent() string {
|
||||||
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
||||||
// Some APIs may block mobile User-Agents, so we use desktop format
|
// Some APIs may block mobile User-Agents, so we use desktop format
|
||||||
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
||||||
|
|
||||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
||||||
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
|
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(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||||
@@ -39,46 +39,48 @@ func getRandomUserAgent() string {
|
|||||||
|
|
||||||
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||||
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||||
func getRandomMacUserAgent() string {
|
// Kept for potential future use
|
||||||
macMajor := rand.Intn(4) + 11 // macOS 11-14
|
// func getRandomMacUserAgent() string {
|
||||||
macMinor := rand.Intn(5) + 4 // Minor 4-8
|
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||||
webkitMajor := rand.Intn(7) + 530
|
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||||
webkitMinor := rand.Intn(7) + 30
|
// webkitMajor := rand.Intn(7) + 530
|
||||||
chromeMajor := rand.Intn(25) + 80
|
// webkitMinor := rand.Intn(7) + 30
|
||||||
chromeBuild := rand.Intn(1500) + 3000
|
// chromeMajor := rand.Intn(25) + 80
|
||||||
chromePatch := rand.Intn(65) + 60
|
// chromeBuild := rand.Intn(1500) + 3000
|
||||||
safariMajor := rand.Intn(7) + 530
|
// chromePatch := rand.Intn(65) + 60
|
||||||
safariMinor := rand.Intn(6) + 30
|
// 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",
|
// return fmt.Sprintf(
|
||||||
macMajor,
|
// "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",
|
||||||
macMinor,
|
// macMajor,
|
||||||
webkitMajor,
|
// macMinor,
|
||||||
webkitMinor,
|
// webkitMajor,
|
||||||
chromeMajor,
|
// webkitMinor,
|
||||||
chromeBuild,
|
// chromeMajor,
|
||||||
chromePatch,
|
// chromeBuild,
|
||||||
safariMajor,
|
// chromePatch,
|
||||||
safariMinor,
|
// safariMajor,
|
||||||
)
|
// safariMinor,
|
||||||
}
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||||
func getRandomDesktopUserAgent() string {
|
// Kept for potential future use
|
||||||
if rand.Intn(2) == 0 {
|
// func getRandomDesktopUserAgent() string {
|
||||||
return getRandomUserAgent() // Windows
|
// if rand.Intn(2) == 0 {
|
||||||
}
|
// return getRandomUserAgent() // Windows
|
||||||
return getRandomMacUserAgent() // Mac
|
// }
|
||||||
}
|
// return getRandomMacUserAgent() // Mac
|
||||||
|
// }
|
||||||
|
|
||||||
// Default timeout values
|
// Default timeout values
|
||||||
const (
|
const (
|
||||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
||||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
||||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
||||||
DefaultMaxRetries = 3 // Default retry count
|
DefaultMaxRetries = 3 // Default retry count
|
||||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||||
@@ -96,9 +98,9 @@ var sharedTransport = &http.Transport{
|
|||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
||||||
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
||||||
DisableCompression: true, // FLAC is already compressed
|
DisableCompression: true, // FLAC is already compressed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared HTTP client for general requests (reuses connections)
|
// Shared HTTP client for general requests (reuses connections)
|
||||||
@@ -267,10 +269,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
// calculateNextDelay calculates the next delay with exponential backoff
|
// calculateNextDelay calculates the next delay with exponential backoff
|
||||||
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
||||||
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||||
if nextDelay > config.MaxDelay {
|
return min(nextDelay, config.MaxDelay)
|
||||||
nextDelay = config.MaxDelay
|
|
||||||
}
|
|
||||||
return nextDelay
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRetryAfterDuration parses Retry-After header and returns duration
|
// getRetryAfterDuration parses Retry-After header and returns duration
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ var (
|
|||||||
func GetLogBuffer() *LogBuffer {
|
func GetLogBuffer() *LogBuffer {
|
||||||
logBufferOnce.Do(func() {
|
logBufferOnce.Do(func() {
|
||||||
globalLogBuffer = &LogBuffer{
|
globalLogBuffer = &LogBuffer{
|
||||||
entries: make([]LogEntry, 0, 500),
|
entries: make([]LogEntry, 0, 1000),
|
||||||
maxSize: 500,
|
maxSize: 1000,
|
||||||
loggingEnabled: false, // Default: disabled for performance
|
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalLogBuffer
|
return globalLogBuffer
|
||||||
|
|||||||
@@ -250,29 +250,30 @@ func msToLRCTimestamp(ms int64) string {
|
|||||||
|
|
||||||
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
||||||
// Use convertToLRCWithMetadata for full LRC with headers
|
// Use convertToLRCWithMetadata for full LRC with headers
|
||||||
func convertToLRC(lyrics *LyricsResponse) string {
|
// Kept for potential future use
|
||||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
// func convertToLRC(lyrics *LyricsResponse) string {
|
||||||
return ""
|
// if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
}
|
// return ""
|
||||||
|
// }
|
||||||
var builder strings.Builder
|
//
|
||||||
|
// var builder strings.Builder
|
||||||
if lyrics.SyncType == "LINE_SYNCED" {
|
//
|
||||||
for _, line := range lyrics.Lines {
|
// if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
// for _, line := range lyrics.Lines {
|
||||||
builder.WriteString(timestamp)
|
// timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||||
builder.WriteString(line.Words)
|
// builder.WriteString(timestamp)
|
||||||
builder.WriteString("\n")
|
// builder.WriteString(line.Words)
|
||||||
}
|
// builder.WriteString("\n")
|
||||||
} else {
|
// }
|
||||||
for _, line := range lyrics.Lines {
|
// } else {
|
||||||
builder.WriteString(line.Words)
|
// for _, line := range lyrics.Lines {
|
||||||
builder.WriteString("\n")
|
// builder.WriteString(line.Words)
|
||||||
}
|
// builder.WriteString("\n")
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
return builder.String()
|
//
|
||||||
}
|
// return builder.String()
|
||||||
|
// }
|
||||||
|
|
||||||
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
||||||
// Includes [ti:], [ar:], [by:] headers
|
// Includes [ti:], [ar:], [by:] headers
|
||||||
|
|||||||
@@ -400,8 +400,9 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
|
|
||||||
// AudioQuality represents audio quality info from a FLAC file
|
// AudioQuality represents audio quality info from a FLAC file
|
||||||
type AudioQuality struct {
|
type AudioQuality struct {
|
||||||
BitDepth int `json:"bit_depth"`
|
BitDepth int `json:"bit_depth"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
|
TotalSamples int64 `json:"total_samples"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||||
@@ -446,9 +447,17 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
// Parse bits per sample (5 bits)
|
// Parse bits per sample (5 bits)
|
||||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
||||||
|
|
||||||
|
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
|
||||||
|
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
||||||
|
int64(streamInfo[14])<<24 |
|
||||||
|
int64(streamInfo[15])<<16 |
|
||||||
|
int64(streamInfo[16])<<8 |
|
||||||
|
int64(streamInfo[17])
|
||||||
|
|
||||||
return AudioQuality{
|
return AudioQuality{
|
||||||
BitDepth: bitsPerSample,
|
BitDepth: bitsPerSample,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
|
TotalSamples: totalSamples,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +478,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// M4A (MP4/AAC) Metadata Embedding
|
// M4A (MP4/AAC) Metadata Embedding
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -490,7 +498,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find udta atom inside moov, or create one
|
// 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)
|
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||||
|
|
||||||
// Build new metadata atoms
|
// Build new metadata atoms
|
||||||
@@ -499,12 +507,12 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
|||||||
var newData []byte
|
var newData []byte
|
||||||
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||||
// udta exists, find meta inside it or replace
|
// 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)
|
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||||
|
|
||||||
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||||
// Replace existing meta atom
|
// 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, data[:metaPos]...)
|
||||||
newData = append(newData, metaAtom...)
|
newData = append(newData, metaAtom...)
|
||||||
newData = append(newData, data[metaPos+metaSize:]...)
|
newData = append(newData, data[metaPos+metaSize:]...)
|
||||||
@@ -562,7 +570,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
|||||||
// findAtom finds an atom by name starting from offset
|
// findAtom finds an atom by name starting from offset
|
||||||
func findAtom(data []byte, name string, offset int) int {
|
func findAtom(data []byte, name string, offset int) int {
|
||||||
for i := offset; i < len(data)-8; {
|
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 {
|
if size < 8 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
||||||
}
|
}
|
||||||
|
|
||||||
func preWarmTidalCache(isrc, trackName, artistName string) {
|
func preWarmTidalCache(isrc, _, _ string) {
|
||||||
downloader := NewTidalDownloader()
|
downloader := NewTidalDownloader()
|
||||||
track, err := downloader.SearchTrackByISRC(isrc)
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type ItemProgress struct {
|
|||||||
ItemID string `json:"item_id"`
|
ItemID string `json:"item_id"`
|
||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||||
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
@@ -204,11 +204,12 @@ func setDownloadDir(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getDownloadDir returns the default download directory
|
// getDownloadDir returns the default download directory
|
||||||
func getDownloadDir() string {
|
// Kept for potential future use
|
||||||
downloadDirMu.RLock()
|
// func getDownloadDir() string {
|
||||||
defer downloadDirMu.RUnlock()
|
// downloadDirMu.RLock()
|
||||||
return downloadDir
|
// defer downloadDirMu.RUnlock()
|
||||||
}
|
// return downloadDir
|
||||||
|
// }
|
||||||
|
|
||||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||||
type ItemProgressWriter struct {
|
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
|
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
|
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
||||||
|
return 0, ErrDownloadCancelled
|
||||||
|
}
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -12,6 +14,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QobuzDownloader handles Qobuz downloads
|
// QobuzDownloader handles Qobuz downloads
|
||||||
@@ -63,24 +66,27 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
// Split expected artists by common separators (comma, feat, ft., &, and)
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
expectedArtists := qobuzSplitArtists(normExpected)
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
foundArtists := qobuzSplitArtists(normFound)
|
||||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
|
||||||
|
|
||||||
foundFirst := strings.Split(normFound, ",")[0]
|
// Check if ANY expected artist matches ANY found artist
|
||||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
for _, exp := range expectedArtists {
|
||||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
for _, fnd := range foundArtists {
|
||||||
foundFirst = strings.TrimSpace(foundFirst)
|
if exp == fnd {
|
||||||
|
return true
|
||||||
if expectedFirst == foundFirst {
|
}
|
||||||
return true
|
// Also check contains for partial matches
|
||||||
}
|
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
||||||
|
return true
|
||||||
// Check if first artist is contained in the other
|
}
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
// Check same words different order
|
||||||
return true
|
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)
|
// 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
|
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
|
// qobuzTitlesMatch checks if track titles are similar enough
|
||||||
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||||
@@ -271,14 +338,15 @@ func qobuzIsLatinScript(s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||||
func qobuzIsASCIIString(s string) bool {
|
// Kept for potential future use
|
||||||
for _, r := range s {
|
// func qobuzIsASCIIString(s string) bool {
|
||||||
if r > 127 {
|
// for _, r := range s {
|
||||||
return false
|
// if r > 127 {
|
||||||
}
|
// return false
|
||||||
}
|
// }
|
||||||
return true
|
// }
|
||||||
}
|
// return true
|
||||||
|
// }
|
||||||
|
|
||||||
// containsQueryQobuz checks if a query already exists in the list
|
// containsQueryQobuz checks if a query already exists in the list
|
||||||
func containsQueryQobuz(queries []string, query string) bool {
|
func containsQueryQobuz(queries []string, query string) bool {
|
||||||
@@ -301,6 +369,35 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
return globalQobuzDownloader
|
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
|
// GetAvailableAPIs returns list of available Qobuz APIs
|
||||||
// Uses same APIs as PC version for compatibility
|
// Uses same APIs as PC version for compatibility
|
||||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
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)
|
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
// qobuzAPIResult holds the result from a parallel API request
|
||||||
// Uses same URL format as PC version: /api/stream?trackId={id}&quality={quality}
|
type qobuzAPIResult struct {
|
||||||
func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) {
|
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 {
|
if len(apis) == 0 {
|
||||||
return "", "", fmt.Errorf("no APIs available")
|
return "", "", fmt.Errorf("no APIs available")
|
||||||
}
|
}
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||||
retryConfig := DefaultRetryConfig()
|
|
||||||
var errors []string
|
|
||||||
|
|
||||||
|
resultChan := make(chan qobuzAPIResult, len(apis))
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Start all requests in parallel
|
||||||
for _, apiURL := range apis {
|
for _, apiURL := range apis {
|
||||||
// All APIs now use same format: https://domain/api/stream?trackId={id}&quality={quality}
|
go func(api string) {
|
||||||
// The apiURL already includes the path, just append trackID and quality
|
reqStart := time.Now()
|
||||||
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
|
|
||||||
|
|
||||||
GoLog("[Qobuz] Trying: %s\n", reqURL)
|
client := &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", reqURL, nil)
|
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||||
if err != nil {
|
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := DoRequestWithRetry(client, req, retryConfig)
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
resp, err := client.Do(req)
|
||||||
resp.Body.Close()
|
if err != nil {
|
||||||
if err != nil {
|
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
|
return
|
||||||
continue
|
}
|
||||||
}
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Check if response is HTML (error page)
|
if resp.StatusCode != 200 {
|
||||||
if len(body) > 0 && body[0] == '<' {
|
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "received HTML instead of JSON"))
|
return
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check for error in JSON response
|
body, err := io.ReadAll(resp.Body)
|
||||||
var errorResp struct {
|
if err != nil {
|
||||||
Error string `json:"error"`
|
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||||
}
|
return
|
||||||
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
|
}
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, errorResp.Error))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
// Check if response is HTML (error page)
|
||||||
URL string `json:"url"`
|
if len(body) > 0 && body[0] == '<' {
|
||||||
}
|
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
return
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "invalid JSON: "+err.Error()))
|
}
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.URL != "" {
|
// Check for error in JSON response
|
||||||
GoLog("[Qobuz] Got download URL from: %s\n", apiURL)
|
var errorResp struct {
|
||||||
return apiURL, result.URL, nil
|
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)
|
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) {
|
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||||
apis := q.GetAvailableAPIs()
|
apis := q.GetAvailableAPIs()
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return "", fmt.Errorf("no Qobuz API available")
|
return "", fmt.Errorf("no Qobuz API available")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, downloadURL, err := getQobuzDownloadURLSequential(apis, trackID, quality)
|
// Use parallel approach - request from all APIs simultaneously
|
||||||
|
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
// Initialize item progress (required for all downloads)
|
// Initialize item progress (required for all downloads)
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
|
ctx = initDownloadCancel(itemID)
|
||||||
|
defer clearDownloadCancel(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -774,6 +929,9 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
// Check for any errors
|
// Check for any errors
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if flushErr != nil {
|
if flushErr != nil {
|
||||||
@@ -823,8 +981,23 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
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
|
// 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 {
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||||
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||||
// For Qobuz we need to search again to get full track info, but we can use the ID
|
// 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
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return QobuzDownloadResult{}, ErrDownloadCancelled
|
||||||
|
}
|
||||||
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -17,13 +16,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||||
|
|
||||||
// Cache TTL settings
|
// Cache TTL settings
|
||||||
artistCacheTTL = 10 * time.Minute
|
artistCacheTTL = 10 * time.Minute
|
||||||
@@ -69,8 +68,10 @@ var (
|
|||||||
credentialsMu sync.RWMutex
|
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
|
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||||
// Pass empty strings to use default credentials
|
|
||||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||||
credentialsMu.Lock()
|
credentialsMu.Lock()
|
||||||
defer credentialsMu.Unlock()
|
defer credentialsMu.Unlock()
|
||||||
@@ -78,39 +79,56 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
|
|||||||
customClientSecret = clientSecret
|
customClientSecret = clientSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCredentials returns the current credentials (custom or default)
|
// HasSpotifyCredentials checks if Spotify credentials are configured
|
||||||
func getCredentials() (string, string) {
|
func HasSpotifyCredentials() bool {
|
||||||
credentialsMu.RLock()
|
credentialsMu.RLock()
|
||||||
defer credentialsMu.RUnlock()
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
|
// Check custom credentials first
|
||||||
if customClientID != "" && customClientSecret != "" {
|
if customClientID != "" && customClientSecret != "" {
|
||||||
return customClientID, customClientSecret
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to default credentials
|
// Check environment variables
|
||||||
|
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
if clientID == "" {
|
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
|
||||||
clientID = string(decoded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
if clientSecret == "" {
|
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
if clientID != "" && clientSecret != "" {
|
||||||
clientSecret = string(decoded)
|
return clientID, clientSecret, nil
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return clientID, clientSecret
|
// No credentials available
|
||||||
|
return "", "", ErrNoSpotifyCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpotifyMetadataClient creates a new Spotify client
|
// NewSpotifyMetadataClient creates a new Spotify client
|
||||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
// Returns error if credentials are not configured
|
||||||
src := rand.NewSource(time.Now().UnixNano())
|
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)
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
clientID, clientSecret := getCredentials()
|
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||||
@@ -122,7 +140,7 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
|||||||
albumCache: make(map[string]*cacheEntry),
|
albumCache: make(map[string]*cacheEntry),
|
||||||
}
|
}
|
||||||
c.userAgent = c.randomUserAgent()
|
c.userAgent = c.randomUserAgent()
|
||||||
return c
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackMetadata represents track information
|
// TrackMetadata represents track information
|
||||||
@@ -140,6 +158,7 @@ type TrackMetadata struct {
|
|||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
|
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumTrackMetadata holds per-track info for album/playlist
|
// AlbumTrackMetadata holds per-track info for album/playlist
|
||||||
@@ -159,6 +178,7 @@ type AlbumTrackMetadata struct {
|
|||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
AlbumID string `json:"album_id,omitempty"`
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
AlbumURL string `json:"album_url,omitempty"`
|
AlbumURL string `json:"album_url,omitempty"`
|
||||||
|
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumInfoMetadata holds album information
|
// AlbumInfoMetadata holds album information
|
||||||
@@ -283,6 +303,7 @@ type albumSimplified struct {
|
|||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
ExternalURL externalURL `json:"external_urls"`
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
Artists []artist `json:"artists"`
|
Artists []artist `json:"artists"`
|
||||||
|
AlbumType string `json:"album_type"` // album, single, compilation
|
||||||
}
|
}
|
||||||
|
|
||||||
type trackFull struct {
|
type trackFull struct {
|
||||||
@@ -363,6 +384,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
DiscNumber: track.DiscNumber,
|
DiscNumber: track.DiscNumber,
|
||||||
ExternalURL: track.ExternalURL.Spotify,
|
ExternalURL: track.ExternalURL.Spotify,
|
||||||
ISRC: track.ExternalID.ISRC,
|
ISRC: track.ExternalID.ISRC,
|
||||||
|
AlbumType: track.Album.AlbumType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,10 +417,10 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
Artists struct {
|
Artists struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Followers struct {
|
Followers struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"followers"`
|
} `json:"followers"`
|
||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
@@ -430,6 +452,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
DiscNumber: track.DiscNumber,
|
DiscNumber: track.DiscNumber,
|
||||||
ExternalURL: track.ExternalURL.Spotify,
|
ExternalURL: track.ExternalURL.Spotify,
|
||||||
ISRC: track.ExternalID.ISRC,
|
ISRC: track.ExternalID.ISRC,
|
||||||
|
AlbumType: track.Album.AlbumType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,10 +778,10 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
|
|
||||||
// Fetch artist info
|
// Fetch artist info
|
||||||
var artistData struct {
|
var artistData struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Followers struct {
|
Followers struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"followers"`
|
} `json:"followers"`
|
||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
@@ -941,15 +964,15 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|||||||
defer c.rngMu.Unlock()
|
defer c.rngMu.Unlock()
|
||||||
|
|
||||||
// Use Mac User-Agent format (same as PC version)
|
// Use Mac User-Agent format (same as PC version)
|
||||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||||
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
||||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||||
|
|
||||||
return fmt.Sprintf(
|
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",
|
"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",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"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)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
|
// normalizeTitle normalizes a track title for comparison
|
||||||
func normalizeTitle(title string) string {
|
// Kept for potential future use
|
||||||
normalized := strings.ToLower(strings.TrimSpace(title))
|
// func normalizeTitle(title string) string {
|
||||||
|
// normalized := strings.ToLower(strings.TrimSpace(title))
|
||||||
// Remove common suffixes in parentheses or brackets
|
//
|
||||||
suffixPatterns := []string{
|
// // Remove common suffixes in parentheses or brackets
|
||||||
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
// suffixPatterns := []string{
|
||||||
" (bonus track)", " (single)", " (album version)", " (radio edit)",
|
// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
||||||
" [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
|
// " (bonus track)", " (single)", " (album version)", " (radio edit)",
|
||||||
}
|
// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
|
||||||
for _, suffix := range suffixPatterns {
|
// }
|
||||||
normalized = strings.TrimSuffix(normalized, suffix)
|
// for _, suffix := range suffixPatterns {
|
||||||
}
|
// normalized = strings.TrimSuffix(normalized, suffix)
|
||||||
|
// }
|
||||||
// Remove multiple spaces
|
//
|
||||||
for strings.Contains(normalized, " ") {
|
// // Remove multiple spaces
|
||||||
normalized = strings.ReplaceAll(normalized, " ", " ")
|
// for strings.Contains(normalized, " ") {
|
||||||
}
|
// normalized = strings.ReplaceAll(normalized, " ", " ")
|
||||||
|
// }
|
||||||
return normalized
|
//
|
||||||
}
|
// return normalized
|
||||||
|
// }
|
||||||
|
|
||||||
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
||||||
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
||||||
@@ -638,103 +641,144 @@ type TidalDownloadInfo struct {
|
|||||||
SampleRate int
|
SampleRate int
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDownloadURLSequential requests download URL from APIs sequentially
|
// tidalAPIResult holds the result from a parallel API request
|
||||||
|
type tidalAPIResult struct {
|
||||||
|
apiURL string
|
||||||
|
info TidalDownloadInfo
|
||||||
|
err error
|
||||||
|
duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDownloadURLParallel requests download URL from all APIs in parallel
|
||||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||||
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
// "Siapa cepat dia dapat" - first success wins
|
||||||
|
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||||
}
|
}
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||||
retryConfig := DefaultRetryConfig()
|
|
||||||
var errors []string
|
|
||||||
|
|
||||||
|
resultChan := make(chan tidalAPIResult, len(apis))
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Start all requests in parallel
|
||||||
for _, apiURL := range apis {
|
for _, apiURL := range apis {
|
||||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
|
go func(api string) {
|
||||||
GoLog("[Tidal] Trying API: %s\n", reqURL)
|
reqStart := time.Now()
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", reqURL, nil)
|
// Create client with timeout for parallel requests
|
||||||
if err != nil {
|
client := &http.Client{
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
Timeout: 15 * time.Second,
|
||||||
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)
|
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||||
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)
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
var v1Responses []struct {
|
if err != nil {
|
||||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||||
}
|
return
|
||||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
}
|
||||||
for _, item := range v1Responses {
|
|
||||||
if item.OriginalTrackURL != "" {
|
resp, err := client.Do(req)
|
||||||
// v1 format doesn't have quality info, assume 16-bit/44.1kHz
|
if err != nil {
|
||||||
info := TidalDownloadInfo{
|
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||||
URL: item.OriginalTrackURL,
|
return
|
||||||
BitDepth: 16,
|
}
|
||||||
SampleRate: 44100,
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
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 {
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try v2 format first (object with manifest)
|
||||||
|
var v2Response TidalAPIResponseV2
|
||||||
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||||
|
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||||
|
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info := TidalDownloadInfo{
|
||||||
|
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||||
|
BitDepth: v2Response.Data.BitDepth,
|
||||||
|
SampleRate: v2Response.Data.SampleRate,
|
||||||
|
}
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 != "" {
|
||||||
|
info := TidalDownloadInfo{
|
||||||
|
URL: item.OriginalTrackURL,
|
||||||
|
BitDepth: 16,
|
||||||
|
SampleRate: 44100,
|
||||||
|
}
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return apiURL, info, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response"))
|
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
|
||||||
|
|
||||||
|
for i := 0; i < len(apis); i++ {
|
||||||
|
result := <-resultChan
|
||||||
|
if result.err == nil {
|
||||||
|
// 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 - drain remaining results to avoid goroutine leaks
|
||||||
|
go func(remaining int) {
|
||||||
|
for j := 0; j < remaining; j++ {
|
||||||
|
<-resultChan
|
||||||
|
}
|
||||||
|
}(len(apis) - i - 1)
|
||||||
|
|
||||||
|
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)
|
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) {
|
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
|
||||||
apis := t.GetAvailableAPIs()
|
apis := t.GetAvailableAPIs()
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
|
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 {
|
if err != nil {
|
||||||
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
|
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
@@ -844,29 +888,45 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
// DownloadFile downloads a file from URL with progress tracking
|
// DownloadFile downloads a file from URL with progress tracking
|
||||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
// Handle manifest-based download (DASH/BTS)
|
// Handle manifest-based download (DASH/BTS)
|
||||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||||
// Initialize progress tracking for manifest downloads
|
// Initialize progress tracking for manifest downloads
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(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
|
// Initialize item progress for direct downloads
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
|
ctx = initDownloadCancel(itemID)
|
||||||
|
defer clearDownloadCancel(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(t.client, req)
|
resp, err := DoRequestWithUserAgent(t.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -906,6 +966,9 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
// Check for any errors
|
// Check for any errors
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if flushErr != nil {
|
if flushErr != nil {
|
||||||
@@ -926,7 +989,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return nil
|
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...")
|
fmt.Println("[Tidal] Parsing manifest...")
|
||||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -945,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))])
|
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||||
// Note: Progress tracking is initialized by the caller (DownloadFile)
|
// 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 {
|
if err != nil {
|
||||||
GoLog("[Tidal] BTS request creation failed: %v\n", err)
|
GoLog("[Tidal] BTS request creation failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
@@ -953,6 +1020,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
GoLog("[Tidal] BTS download failed: %v\n", err)
|
GoLog("[Tidal] BTS download failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -988,6 +1058,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
@@ -1020,10 +1093,25 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
|
|
||||||
// Download initialization segment
|
// Download initialization segment
|
||||||
GoLog("[Tidal] Downloading init segment...\n")
|
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 {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
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)
|
GoLog("[Tidal] Init segment download failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to download init segment: %w", err)
|
return fmt.Errorf("failed to download init segment: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1039,6 +1127,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
GoLog("[Tidal] Init segment write failed: %v\n", err)
|
GoLog("[Tidal] Init segment write failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to write init segment: %w", err)
|
return fmt.Errorf("failed to write init segment: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1046,6 +1137,12 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
// Download media segments with progress
|
// Download media segments with progress
|
||||||
totalSegments := len(mediaURLs)
|
totalSegments := len(mediaURLs)
|
||||||
for i, mediaURL := range mediaURLs {
|
for i, mediaURL := range mediaURLs {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
out.Close()
|
||||||
|
os.Remove(m4aPath)
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
|
||||||
if i%10 == 0 || i == totalSegments-1 {
|
if i%10 == 0 || i == totalSegments-1 {
|
||||||
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
||||||
}
|
}
|
||||||
@@ -1056,10 +1153,20 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
SetItemProgress(itemID, progress, 0, 0)
|
SetItemProgress(itemID, progress, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Get(mediaURL)
|
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
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)
|
GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err)
|
||||||
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
||||||
}
|
}
|
||||||
@@ -1075,6 +1182,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
||||||
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
||||||
}
|
}
|
||||||
@@ -1119,24 +1229,27 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
// Split artists by common separators (comma, feat, ft., &, and)
|
||||||
spotifyFirst := strings.Split(normSpotify, ",")[0]
|
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
|
||||||
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
|
spotifyArtists := splitArtists(normSpotify)
|
||||||
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
|
tidalArtists := splitArtists(normTidal)
|
||||||
spotifyFirst = strings.TrimSpace(spotifyFirst)
|
|
||||||
|
|
||||||
tidalFirst := strings.Split(normTidal, ",")[0]
|
// Check if ANY expected artist matches ANY found artist
|
||||||
tidalFirst = strings.Split(tidalFirst, " feat")[0]
|
for _, exp := range spotifyArtists {
|
||||||
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
|
for _, fnd := range tidalArtists {
|
||||||
tidalFirst = strings.TrimSpace(tidalFirst)
|
if exp == fnd {
|
||||||
|
return true
|
||||||
if spotifyFirst == tidalFirst {
|
}
|
||||||
return true
|
// Also check contains for partial matches
|
||||||
}
|
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
||||||
|
return true
|
||||||
// Check if first artist is contained in the other
|
}
|
||||||
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
|
// Check same words different order
|
||||||
return true
|
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)
|
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||||
@@ -1152,6 +1265,67 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
|||||||
return false
|
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
|
// titlesMatch checks if track titles are similar enough
|
||||||
func titlesMatch(expectedTitle, foundTitle string) bool {
|
func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||||
@@ -1326,14 +1500,15 @@ func isLatinScript(s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isASCIIString checks if a string contains only ASCII characters
|
// isASCIIString checks if a string contains only ASCII characters
|
||||||
func isASCIIString(s string) bool {
|
// Kept for potential future use
|
||||||
for _, r := range s {
|
// func isASCIIString(s string) bool {
|
||||||
if r > 127 {
|
// for _, r := range s {
|
||||||
return false
|
// if r > 127 {
|
||||||
}
|
// return false
|
||||||
}
|
// }
|
||||||
return true
|
// }
|
||||||
}
|
// return true
|
||||||
|
// }
|
||||||
|
|
||||||
// downloadFromTidal downloads a track using the request parameters
|
// downloadFromTidal downloads a track using the request parameters
|
||||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||||
@@ -1350,8 +1525,24 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
var track *TidalTrack
|
var track *TidalTrack
|
||||||
var err error
|
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
|
// 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 {
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
||||||
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
||||||
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
|
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
|
||||||
@@ -1385,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 != "" {
|
if track == nil && req.SpotifyID != "" {
|
||||||
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||||
var tidalURL string
|
var tidalURL string
|
||||||
@@ -1563,6 +1754,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}())
|
}())
|
||||||
|
|
||||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
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)
|
GoLog("[Tidal] Download failed with error: %v\n", err)
|
||||||
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,12 @@ import Gobackend // Import Go framework
|
|||||||
GobackendClearItemProgress(itemId)
|
GobackendClearItemProgress(itemId)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "cancelDownload":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendCancelDownload(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
case "setDownloadDirectory":
|
case "setDownloadDirectory":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let path = args["path"] as! String
|
let path = args["path"] as! String
|
||||||
@@ -256,6 +262,10 @@ import Gobackend // Import Go framework
|
|||||||
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "hasSpotifyCredentials":
|
||||||
|
let hasCredentials = GobackendCheckSpotifyCredentials()
|
||||||
|
return hasCredentials
|
||||||
|
|
||||||
// Log methods
|
// Log methods
|
||||||
case "getLogs":
|
case "getLogs":
|
||||||
let response = GobackendGetLogs()
|
let response = GobackendGetLogs()
|
||||||
@@ -281,6 +291,303 @@ import Gobackend // Import Go framework
|
|||||||
GobackendSetLoggingEnabled(enabled)
|
GobackendSetLoggingEnabled(enabled)
|
||||||
return nil
|
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:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -4,6 +4,23 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleLocalizations</key>
|
||||||
|
<array>
|
||||||
|
<string>en</string>
|
||||||
|
<string>de</string>
|
||||||
|
<string>es</string>
|
||||||
|
<string>fr</string>
|
||||||
|
<string>hi</string>
|
||||||
|
<string>id</string>
|
||||||
|
<string>ja</string>
|
||||||
|
<string>ko</string>
|
||||||
|
<string>nl</string>
|
||||||
|
<string>pt</string>
|
||||||
|
<string>ru</string>
|
||||||
|
<string>zh</string>
|
||||||
|
<string>zh-Hans</string>
|
||||||
|
<string>zh-Hant</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>SpotiFLAC</string>
|
<string>SpotiFLAC</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:spotiflac_android/screens/main_shell.dart';
|
import 'package:spotiflac_android/screens/main_shell.dart';
|
||||||
import 'package:spotiflac_android/screens/setup_screen.dart';
|
import 'package:spotiflac_android/screens/setup_screen.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
||||||
@@ -31,6 +33,13 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final router = ref.watch(_routerProvider);
|
final router = ref.watch(_routerProvider);
|
||||||
|
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
||||||
|
|
||||||
|
// Convert locale string to Locale object
|
||||||
|
Locale? locale;
|
||||||
|
if (localeString != 'system') {
|
||||||
|
locale = Locale(localeString);
|
||||||
|
}
|
||||||
|
|
||||||
return DynamicColorWrapper(
|
return DynamicColorWrapper(
|
||||||
builder: (lightTheme, darkTheme, themeMode) {
|
builder: (lightTheme, darkTheme, themeMode) {
|
||||||
@@ -43,6 +52,15 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||||
themeAnimationCurve: Curves.easeInOut,
|
themeAnimationCurve: Curves.easeInOut,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
|
// Localization
|
||||||
|
locale: locale, // null = follow system
|
||||||
|
localizationsDelegates: const [
|
||||||
|
AppLocalizations.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.2.5';
|
static const String version = '3.1.0';
|
||||||
static const String buildNumber = '47';
|
static const String buildNumber = '59';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/app.dart';
|
import 'package:spotiflac_android/app.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.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/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/share_intent_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
|
/// 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});
|
const _EagerInitialization({required this.child});
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
@override
|
@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
|
// Eagerly initialize download history provider to load from storage
|
||||||
ref.watch(downloadHistoryProvider);
|
ref.watch(downloadHistoryProvider);
|
||||||
return child;
|
return widget.child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ enum DownloadErrorType {
|
|||||||
notFound, // Track not found on any service
|
notFound, // Track not found on any service
|
||||||
rateLimit, // Rate limited by service
|
rateLimit, // Rate limited by service
|
||||||
network, // Network/connection error
|
network, // Network/connection error
|
||||||
|
permission, // File/folder permission error
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
@@ -88,6 +89,8 @@ class DownloadItem {
|
|||||||
return 'Rate limit reached, try again later';
|
return 'Rate limit reached, try again later';
|
||||||
case DownloadErrorType.network:
|
case DownloadErrorType.network:
|
||||||
return 'Connection failed, check your internet';
|
return 'Connection failed, check your internet';
|
||||||
|
case DownloadErrorType.permission:
|
||||||
|
return 'Cannot write to folder, check storage permission';
|
||||||
default:
|
default:
|
||||||
return error ?? 'An error occurred';
|
return error ?? 'An error occurred';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,4 +51,5 @@ const _$DownloadErrorTypeEnumMap = {
|
|||||||
DownloadErrorType.notFound: 'notFound',
|
DownloadErrorType.notFound: 'notFound',
|
||||||
DownloadErrorType.rateLimit: 'rateLimit',
|
DownloadErrorType.rateLimit: 'rateLimit',
|
||||||
DownloadErrorType.network: 'network',
|
DownloadErrorType.network: 'network',
|
||||||
|
DownloadErrorType.permission: 'permission',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,12 +18,19 @@ class AppSettings {
|
|||||||
final bool hasSearchedBefore; // Hide helper text after first search
|
final bool hasSearchedBefore; // Hide helper text after first search
|
||||||
final String folderOrganization; // none, artist, album, artist_album
|
final String folderOrganization; // none, artist, album, artist_album
|
||||||
final String historyViewMode; // list, grid
|
final String historyViewMode; // list, grid
|
||||||
|
final String historyFilterMode; // all, albums, singles
|
||||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||||
final bool enableLogging; // Enable detailed logging for debugging
|
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({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -40,12 +47,19 @@ class AppSettings {
|
|||||||
this.hasSearchedBefore = false, // Default: show helper text
|
this.hasSearchedBefore = false, // Default: show helper text
|
||||||
this.folderOrganization = 'none', // Default: no folder organization
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
this.historyViewMode = 'grid', // Default: grid view
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
|
this.historyFilterMode = 'all', // Default: show all
|
||||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||||
this.spotifyClientId = '', // Default: use built-in credentials
|
this.spotifyClientId = '', // Default: use built-in credentials
|
||||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||||
this.enableLogging = false, // Default: disabled for performance
|
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({
|
AppSettings copyWith({
|
||||||
@@ -63,12 +77,20 @@ class AppSettings {
|
|||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
String? historyViewMode,
|
String? historyViewMode,
|
||||||
|
String? historyFilterMode,
|
||||||
bool? askQualityBeforeDownload,
|
bool? askQualityBeforeDownload,
|
||||||
String? spotifyClientId,
|
String? spotifyClientId,
|
||||||
String? spotifyClientSecret,
|
String? spotifyClientSecret,
|
||||||
bool? useCustomSpotifyCredentials,
|
bool? useCustomSpotifyCredentials,
|
||||||
String? metadataSource,
|
String? metadataSource,
|
||||||
bool? enableLogging,
|
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(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -85,12 +107,19 @@ class AppSettings {
|
|||||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
|
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
metadataSource: metadataSource ?? this.metadataSource,
|
metadataSource: metadataSource ?? this.metadataSource,
|
||||||
enableLogging: enableLogging ?? this.enableLogging,
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
|
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
@@ -28,6 +29,13 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||||
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
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) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -46,10 +54,17 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'historyViewMode': instance.historyViewMode,
|
'historyViewMode': instance.historyViewMode,
|
||||||
|
'historyFilterMode': instance.historyFilterMode,
|
||||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
'spotifyClientId': instance.spotifyClientId,
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||||
'metadataSource': instance.metadataSource,
|
'metadataSource': instance.metadataSource,
|
||||||
'enableLogging': instance.enableLogging,
|
'enableLogging': instance.enableLogging,
|
||||||
|
'useExtensionProviders': instance.useExtensionProviders,
|
||||||
|
'searchProvider': instance.searchProvider,
|
||||||
|
'separateSingles': instance.separateSingles,
|
||||||
|
'albumFolderStructure': instance.albumFolderStructure,
|
||||||
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
|
'locale': instance.locale,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class Track {
|
|||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
final String? deezerId;
|
final String? deezerId;
|
||||||
final ServiceAvailability? availability;
|
final ServiceAvailability? availability;
|
||||||
|
final String? source; // Extension ID that provided this track (null for built-in sources)
|
||||||
|
final String? albumType; // album, single, ep, compilation (from metadata API)
|
||||||
|
final String? itemType; // track, album, playlist - for extension search results
|
||||||
|
|
||||||
const Track({
|
const Track({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -33,10 +36,31 @@ class Track {
|
|||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
this.deezerId,
|
this.deezerId,
|
||||||
this.availability,
|
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);
|
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||||
|
|
||||||
|
/// Check if this track is from an extension
|
||||||
|
bool get isFromExtension => source != null && source!.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
|||||||
: ServiceAvailability.fromJson(
|
: ServiceAvailability.fromJson(
|
||||||
json['availability'] as Map<String, dynamic>,
|
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>{
|
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||||
@@ -40,6 +43,9 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
|||||||
'releaseDate': instance.releaseDate,
|
'releaseDate': instance.releaseDate,
|
||||||
'deezerId': instance.deezerId,
|
'deezerId': instance.deezerId,
|
||||||
'availability': instance.availability,
|
'availability': instance.availability,
|
||||||
|
'source': instance.source,
|
||||||
|
'albumType': instance.albumType,
|
||||||
|
'itemType': instance.itemType,
|
||||||
};
|
};
|
||||||
|
|
||||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
@@ -60,18 +60,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
|
|
||||||
/// Apply current Spotify credentials to Go backend
|
/// Apply current Spotify credentials to Go backend
|
||||||
Future<void> _applySpotifyCredentials() async {
|
Future<void> _applySpotifyCredentials() async {
|
||||||
// Only apply custom credentials if enabled and both fields are set
|
// Only apply if both fields are set
|
||||||
if (state.useCustomSpotifyCredentials &&
|
if (state.spotifyClientId.isNotEmpty &&
|
||||||
state.spotifyClientId.isNotEmpty &&
|
|
||||||
state.spotifyClientSecret.isNotEmpty) {
|
state.spotifyClientSecret.isNotEmpty) {
|
||||||
await PlatformBridge.setSpotifyCredentials(
|
await PlatformBridge.setSpotifyCredentials(
|
||||||
state.spotifyClientId,
|
state.spotifyClientId,
|
||||||
state.spotifyClientSecret,
|
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) {
|
void setDefaultService(String service) {
|
||||||
@@ -148,6 +146,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setHistoryFilterMode(String mode) {
|
||||||
|
state = state.copyWith(historyFilterMode: mode);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setAskQualityBeforeDownload(bool enabled) {
|
void setAskQualityBeforeDownload(bool enabled) {
|
||||||
state = state.copyWith(askQualityBeforeDownload: enabled);
|
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -192,12 +195,46 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_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) {
|
void setEnableLogging(bool enabled) {
|
||||||
state = state.copyWith(enableLogging: enabled);
|
state = state.copyWith(enableLogging: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
// Sync logging state to LogBuffer
|
// Sync logging state to LogBuffer
|
||||||
LogBuffer.loggingEnabled = enabled;
|
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>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.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');
|
||||||
|
|
||||||
class TrackState {
|
class TrackState {
|
||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
@@ -12,9 +17,14 @@ class TrackState {
|
|||||||
final String? artistId;
|
final String? artistId;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
final String? coverUrl;
|
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<ArtistAlbum>? artistAlbums; // For artist page
|
||||||
|
final List<Track>? artistTopTracks; // Artist's popular tracks
|
||||||
final List<SearchArtist>? searchArtists; // For search results
|
final List<SearchArtist>? searchArtists; // For search results
|
||||||
final bool hasSearchText; // For back button handling
|
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({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
@@ -26,9 +36,14 @@ class TrackState {
|
|||||||
this.artistId,
|
this.artistId,
|
||||||
this.artistName,
|
this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
this.headerImageUrl,
|
||||||
|
this.monthlyListeners,
|
||||||
this.artistAlbums,
|
this.artistAlbums,
|
||||||
|
this.artistTopTracks,
|
||||||
this.searchArtists,
|
this.searchArtists,
|
||||||
this.hasSearchText = false,
|
this.hasSearchText = false,
|
||||||
|
this.isShowingRecentAccess = false,
|
||||||
|
this.searchExtensionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||||
@@ -43,9 +58,14 @@ class TrackState {
|
|||||||
String? artistId,
|
String? artistId,
|
||||||
String? artistName,
|
String? artistName,
|
||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
|
String? headerImageUrl,
|
||||||
|
int? monthlyListeners,
|
||||||
List<ArtistAlbum>? artistAlbums,
|
List<ArtistAlbum>? artistAlbums,
|
||||||
|
List<Track>? artistTopTracks,
|
||||||
List<SearchArtist>? searchArtists,
|
List<SearchArtist>? searchArtists,
|
||||||
bool? hasSearchText,
|
bool? hasSearchText,
|
||||||
|
bool? isShowingRecentAccess,
|
||||||
|
String? searchExtensionId,
|
||||||
}) {
|
}) {
|
||||||
return TrackState(
|
return TrackState(
|
||||||
tracks: tracks ?? this.tracks,
|
tracks: tracks ?? this.tracks,
|
||||||
@@ -57,9 +77,14 @@ class TrackState {
|
|||||||
artistId: artistId ?? this.artistId,
|
artistId: artistId ?? this.artistId,
|
||||||
artistName: artistName ?? this.artistName,
|
artistName: artistName ?? this.artistName,
|
||||||
coverUrl: coverUrl ?? this.coverUrl,
|
coverUrl: coverUrl ?? this.coverUrl,
|
||||||
|
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
|
||||||
|
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
|
||||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||||
|
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
|
||||||
searchArtists: searchArtists ?? this.searchArtists,
|
searchArtists: searchArtists ?? this.searchArtists,
|
||||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
|
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||||
|
searchExtensionId: searchExtensionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +98,7 @@ class ArtistAlbum {
|
|||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String albumType; // album, single, compilation
|
final String albumType; // album, single, compilation
|
||||||
final String artists;
|
final String artists;
|
||||||
|
final String? providerId; // Extension ID if from extension
|
||||||
|
|
||||||
const ArtistAlbum({
|
const ArtistAlbum({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -82,6 +108,7 @@ class ArtistAlbum {
|
|||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
required this.albumType,
|
required this.albumType,
|
||||||
required this.artists,
|
required this.artists,
|
||||||
|
this.providerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +149,67 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
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);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
@@ -207,57 +295,116 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
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
|
// Use Deezer or Spotify based on settings
|
||||||
final source = metadataSource ?? 'deezer';
|
final source = metadataSource ?? 'deezer';
|
||||||
|
|
||||||
// Debug log to show which source is being used
|
_log.i(
|
||||||
// ignore: avoid_print
|
'Search started: source=$source, query="$query", useExtensions=$useExtensions',
|
||||||
print('[Search] Using metadata source: $source for query: "$query"');
|
);
|
||||||
|
|
||||||
Map<String, dynamic> results;
|
Map<String, dynamic> results;
|
||||||
if (source == 'deezer') {
|
List<Track> extensionTracks = [];
|
||||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
|
||||||
// ignore: avoid_print
|
// Try extension providers first if enabled
|
||||||
print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
if (useExtensions) {
|
||||||
} else {
|
try {
|
||||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
_log.d('Calling extension search API...');
|
||||||
// ignore: avoid_print
|
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
||||||
print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
_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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
// Also search with built-in providers
|
||||||
|
if (source == 'deezer') {
|
||||||
|
_log.d('Calling Deezer search API...');
|
||||||
|
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
|
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||||
|
} else {
|
||||||
|
_log.d('Calling Spotify search API...');
|
||||||
|
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
|
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isRequestValid(requestId)) {
|
||||||
|
_log.w('Search request cancelled (requestId=$requestId)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
||||||
|
|
||||||
// Parse tracks with error handling per item
|
// Parse tracks with error handling per item
|
||||||
final tracks = <Track>[];
|
final tracks = <Track>[];
|
||||||
for (final t in trackList) {
|
|
||||||
|
// 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 {
|
try {
|
||||||
if (t is Map<String, dynamic>) {
|
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}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore: avoid_print
|
_log.e('Failed to parse track[$i]: $e', e);
|
||||||
print('[Search] Failed to parse track: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse artists with error handling per item
|
// Parse artists with error handling per item
|
||||||
final artists = <SearchArtist>[];
|
final artists = <SearchArtist>[];
|
||||||
for (final a in artistList) {
|
for (int i = 0; i < artistList.length; i++) {
|
||||||
|
final a = artistList[i];
|
||||||
try {
|
try {
|
||||||
if (a is Map<String, dynamic>) {
|
if (a is Map<String, dynamic>) {
|
||||||
artists.add(_parseSearchArtist(a));
|
artists.add(_parseSearchArtist(a));
|
||||||
|
} else {
|
||||||
|
_log.w('Artist[$i] is not a Map: ${a.runtimeType}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore: avoid_print
|
_log.e('Failed to parse artist[$i]: $e', e);
|
||||||
print('[Search] Failed to parse artist: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ignore: avoid_print
|
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists parsed successfully');
|
||||||
print('[Search] Parsed ${tracks.length} tracks, ${artists.length} artists');
|
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -265,9 +412,56 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return;
|
||||||
// Preserve hasSearchText on error so user stays on search screen
|
_log.e('Search failed: $e', e, stackTrace);
|
||||||
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,6 +486,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
trackNumber: track.trackNumber,
|
trackNumber: track.trackNumber,
|
||||||
discNumber: track.discNumber,
|
discNumber: track.discNumber,
|
||||||
releaseDate: track.releaseDate,
|
releaseDate: track.releaseDate,
|
||||||
|
albumType: track.albumType,
|
||||||
|
source: track.source,
|
||||||
availability: ServiceAvailability(
|
availability: ServiceAvailability(
|
||||||
tidal: availability['tidal'] as bool? ?? false,
|
tidal: availability['tidal'] as bool? ?? false,
|
||||||
qobuz: availability['qobuz'] as bool? ?? false,
|
qobuz: availability['qobuz'] as bool? ?? false,
|
||||||
@@ -319,6 +515,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = state.copyWith(hasSearchText: 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) {
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
return Track(
|
return Track(
|
||||||
id: data['spotify_id'] as String? ?? '',
|
id: data['spotify_id'] as String? ?? '',
|
||||||
@@ -335,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
|
// Handle duration_ms which might be int or double
|
||||||
int durationMs = 0;
|
int durationMs = 0;
|
||||||
final durationValue = data['duration_ms'];
|
final durationValue = data['duration_ms'];
|
||||||
@@ -345,18 +563,24 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
durationMs = durationValue.toInt();
|
durationMs = durationValue.toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get item_type - can be 'track', 'album', or 'playlist'
|
||||||
|
final itemType = data['item_type']?.toString();
|
||||||
|
|
||||||
return Track(
|
return Track(
|
||||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||||
name: (data['name'] ?? '').toString(),
|
name: (data['name'] ?? '').toString(),
|
||||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||||
albumArtist: data['album_artist']?.toString(),
|
albumArtist: data['album_artist']?.toString(),
|
||||||
coverUrl: data['images']?.toString(),
|
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||||
isrc: data['isrc']?.toString(),
|
isrc: data['isrc']?.toString(),
|
||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
|
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||||
|
albumType: data['album_type']?.toString(),
|
||||||
|
itemType: itemType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,9 +590,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
name: data['name'] as String? ?? '',
|
name: data['name'] as String? ?? '',
|
||||||
releaseDate: data['release_date'] as String? ?? '',
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
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',
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
artists: data['artists'] as String? ?? '',
|
artists: data['artists'] as String? ?? '',
|
||||||
|
providerId: data['provider_id']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/track.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
|
|
||||||
/// Simple in-memory cache for album tracks
|
/// Simple in-memory cache for album tracks
|
||||||
class _AlbumCache {
|
class _AlbumCache {
|
||||||
@@ -61,6 +64,19 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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
|
// Priority: widget.tracks > cache > fetch
|
||||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||||
if (_tracks == null) {
|
if (_tracks == null) {
|
||||||
@@ -259,7 +275,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||||
const SizedBox(width: 4),
|
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(
|
FilledButton.icon(
|
||||||
onPressed: () => _downloadAll(context),
|
onPressed: () => _downloadAll(context),
|
||||||
icon: const Icon(Icons.download),
|
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))),
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -288,7 +304,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
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) {
|
void _downloadTrack(BuildContext context, Track track) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality, service) {
|
DownloadServicePicker.show(
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
context,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
trackName: track.name,
|
||||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
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;
|
if (tracks == null || tracks.isEmpty) return;
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality, service) {
|
DownloadServicePicker.show(
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
context,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
trackName: '${tracks.length} tracks',
|
||||||
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
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)
|
/// Build error widget with special handling for rate limit (429)
|
||||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
final isRateLimit = error.contains('429') ||
|
final isRateLimit = error.contains('429') ||
|
||||||
@@ -431,7 +390,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Rate Limited',
|
context.l10n.errorRateLimited,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -439,7 +398,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Too many requests. Please wait a moment and try again.',
|
context.l10n.errorRateLimitedMessage,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontSize: 12,
|
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
|
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||||
class _AlbumTrackItem extends ConsumerWidget {
|
class _AlbumTrackItem extends ConsumerWidget {
|
||||||
final Track track;
|
final Track track;
|
||||||
@@ -674,7 +491,7 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
final fileExists = await File(historyItem.filePath).exists();
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
if (context.mounted) {
|
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;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,50 +1,87 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/track_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/screens/album_screen.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 {
|
class _ArtistCache {
|
||||||
static final Map<String, _CacheEntry> _cache = {};
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
static const Duration _ttl = Duration(minutes: 10);
|
static const Duration _ttl = Duration(minutes: 10);
|
||||||
|
|
||||||
static List<ArtistAlbum>? get(String artistId) {
|
static _CacheEntry? get(String artistId) {
|
||||||
final entry = _cache[artistId];
|
final entry = _cache[artistId];
|
||||||
if (entry == null) return null;
|
if (entry == null) return null;
|
||||||
if (DateTime.now().isAfter(entry.expiresAt)) {
|
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||||
_cache.remove(artistId);
|
_cache.remove(artistId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return entry.albums;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void set(String artistId, List<ArtistAlbum> albums) {
|
static void set(String artistId, {
|
||||||
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
|
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 {
|
class _CacheEntry {
|
||||||
final List<ArtistAlbum> albums;
|
final List<ArtistAlbum> albums;
|
||||||
|
final List<Track>? topTracks;
|
||||||
|
final String? headerImageUrl;
|
||||||
|
final int? monthlyListeners;
|
||||||
final DateTime expiresAt;
|
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 {
|
class ArtistScreen extends ConsumerStatefulWidget {
|
||||||
final String artistId;
|
final String artistId;
|
||||||
final String artistName;
|
final String artistName;
|
||||||
final String? coverUrl;
|
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({
|
const ArtistScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.artistId,
|
required this.artistId,
|
||||||
required this.artistName,
|
required this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
this.headerImageUrl,
|
||||||
|
this.monthlyListeners,
|
||||||
this.albums,
|
this.albums,
|
||||||
|
this.topTracks,
|
||||||
|
this.extensionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -54,14 +91,62 @@ class ArtistScreen extends ConsumerStatefulWidget {
|
|||||||
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||||
bool _isLoadingDiscography = false;
|
bool _isLoadingDiscography = false;
|
||||||
List<ArtistAlbum>? _albums;
|
List<ArtistAlbum>? _albums;
|
||||||
|
List<Track>? _topTracks;
|
||||||
|
String? _headerImageUrl;
|
||||||
|
int? _monthlyListeners;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Priority: widget.albums > cache > fetch
|
|
||||||
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
|
// Record access for recent history
|
||||||
if (_albums == null) {
|
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();
|
_fetchDiscography();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,31 +155,60 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
setState(() => _isLoadingDiscography = true);
|
setState(() => _isLoadingDiscography = true);
|
||||||
try {
|
try {
|
||||||
List<ArtistAlbum> albums;
|
List<ArtistAlbum> albums;
|
||||||
|
List<Track>? topTracks;
|
||||||
|
String? headerImage;
|
||||||
|
int? listeners;
|
||||||
|
|
||||||
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
||||||
if (widget.artistId.startsWith('deezer:')) {
|
if (widget.artistId.startsWith('deezer:')) {
|
||||||
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
||||||
// ignore: avoid_print
|
|
||||||
print('[ArtistScreen] Fetching from Deezer: $deezerArtistId');
|
|
||||||
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
|
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
} else {
|
} else {
|
||||||
// Spotify artist - use fallback method
|
// Spotify artist - use extension handler via URL
|
||||||
// ignore: avoid_print
|
|
||||||
print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}');
|
|
||||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
|
||||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
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
|
// Store in cache (preserve existing values if new ones are null)
|
||||||
_ArtistCache.set(widget.artistId, albums);
|
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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_albums = albums;
|
_albums = albums;
|
||||||
|
_topTracks = topTracks;
|
||||||
|
_headerImageUrl = finalHeaderImage;
|
||||||
|
_monthlyListeners = finalListeners;
|
||||||
_isLoadingDiscography = false;
|
_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) {
|
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||||
return ArtistAlbum(
|
return ArtistAlbum(
|
||||||
id: data['id'] as String? ?? '',
|
id: data['id'] as String? ?? '',
|
||||||
name: data['name'] as String? ?? '',
|
name: data['name'] as String? ?? '',
|
||||||
releaseDate: data['release_date'] as String? ?? '',
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
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',
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
artists: data['artists'] as String? ?? '',
|
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();
|
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: CustomScrollView(
|
||||||
children: [
|
slivers: [
|
||||||
CustomScrollView(
|
_buildHeader(context, colorScheme),
|
||||||
slivers: [
|
if (_isLoadingDiscography)
|
||||||
_buildAppBar(context, colorScheme),
|
const SliverToBoxAdapter(child: Padding(
|
||||||
_buildInfoCard(context, colorScheme),
|
padding: EdgeInsets.all(32),
|
||||||
if (_isLoadingDiscography)
|
child: Center(child: CircularProgressIndicator()),
|
||||||
const SliverToBoxAdapter(child: Padding(
|
)),
|
||||||
padding: EdgeInsets.all(32),
|
if (_error != null)
|
||||||
child: Center(child: CircularProgressIndicator()),
|
SliverToBoxAdapter(child: Padding(
|
||||||
)),
|
padding: const EdgeInsets.all(16),
|
||||||
if (_error != null)
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
SliverToBoxAdapter(child: Padding(
|
)),
|
||||||
padding: const EdgeInsets.all(16),
|
if (!_isLoadingDiscography && _error == null) ...[
|
||||||
child: _buildErrorWidget(_error!, colorScheme),
|
// Popular tracks section
|
||||||
)),
|
if (_topTracks != null && _topTracks!.isNotEmpty)
|
||||||
if (!_isLoadingDiscography && _error == null) ...[
|
SliverToBoxAdapter(child: _buildPopularSection(colorScheme)),
|
||||||
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
|
// Discography sections
|
||||||
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
|
if (albumsOnly.isNotEmpty)
|
||||||
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
|
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)),
|
||||||
],
|
if (singles.isNotEmpty)
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
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) {
|
/// Build Spotify-style header with full-width image and artist name overlay
|
||||||
// Validate image URL - must be non-null, non-empty, and have a valid host
|
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
final hasValidImage = widget.coverUrl != null &&
|
// Use header image if available, otherwise fall back to cover URL
|
||||||
widget.coverUrl!.isNotEmpty &&
|
// Prefer: fetched header > widget header > widget cover
|
||||||
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
|
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(
|
return SliverAppBar(
|
||||||
expandedHeight: 280,
|
expandedHeight: 380,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
stretch: true,
|
stretch: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
@@ -174,49 +334,84 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
|
// Background image - full width, no circular crop
|
||||||
if (hasValidImage)
|
if (hasValidImage)
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
color: Colors.black.withValues(alpha: 0.5),
|
alignment: Alignment.topCenter, // Show top of image (faces)
|
||||||
colorBlendMode: BlendMode.darken,
|
memCacheWidth: 800,
|
||||||
memCacheWidth: 600,
|
placeholder: (context, url) => Container(
|
||||||
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
|
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(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
|
colors: [
|
||||||
stops: const [0.0, 0.7, 1.0],
|
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(
|
// Artist name and listeners at bottom
|
||||||
child: Padding(
|
Positioned(
|
||||||
padding: const EdgeInsets.only(top: 60),
|
left: 16,
|
||||||
child: Container(
|
right: 16,
|
||||||
width: 140,
|
bottom: 16,
|
||||||
height: 140,
|
child: Column(
|
||||||
decoration: BoxDecoration(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
shape: BoxShape.circle,
|
mainAxisSize: MainAxisSize.min,
|
||||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
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(
|
if (listenersText != null) ...[
|
||||||
child: hasValidImage
|
const SizedBox(height: 4),
|
||||||
? CachedNetworkImage(
|
Text(
|
||||||
imageUrl: widget.coverUrl!,
|
listenersText,
|
||||||
fit: BoxFit.cover,
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
memCacheWidth: 280,
|
color: Colors.white.withValues(alpha: 0.8),
|
||||||
errorWidget: (context, url, error) => Container(
|
shadows: [
|
||||||
color: colorScheme.surfaceContainerHighest,
|
Shadow(
|
||||||
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
|
offset: const Offset(0, 1),
|
||||||
),
|
blurRadius: 2,
|
||||||
)
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -224,44 +419,280 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
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),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
/// Build Popular tracks section like Spotify
|
||||||
return SliverToBoxAdapter(
|
Widget _buildPopularSection(ColorScheme colorScheme) {
|
||||||
child: Padding(
|
if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink();
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Card(
|
// Show max 5 tracks
|
||||||
elevation: 0,
|
final tracks = _topTracks!.take(5).toList();
|
||||||
color: colorScheme.surfaceContainerLow,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
return Column(
|
||||||
child: Padding(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.all(20),
|
children: [
|
||||||
child: Column(
|
Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||||
children: [
|
child: Text(
|
||||||
Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
context.l10n.artistPopular,
|
||||||
const SizedBox(height: 8),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
if (_albums != null)
|
fontWeight: FontWeight.bold,
|
||||||
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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
...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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||||
child: Row(
|
child: Text(
|
||||||
children: [
|
'$title (${albums.length})',
|
||||||
Icon(Icons.album, size: 20, color: colorScheme.primary),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
const SizedBox(width: 8),
|
fontWeight: FontWeight.bold,
|
||||||
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 210,
|
height: 220,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
itemCount: albums.length,
|
itemCount: albums.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final album = albums[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),
|
onTap: () => _navigateToAlbum(album),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 140,
|
width: 140,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: Card(
|
child: Column(
|
||||||
elevation: 0,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: colorScheme.surfaceContainerLow,
|
children: [
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
// Album cover
|
||||||
child: Padding(
|
ClipRRect(
|
||||||
padding: const EdgeInsets.all(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Column(
|
child: album.coverUrl != null
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
? CachedNetworkImage(
|
||||||
children: [
|
imageUrl: album.coverUrl!,
|
||||||
ClipRRect(
|
width: 140,
|
||||||
borderRadius: BorderRadius.circular(12),
|
height: 140,
|
||||||
child: album.coverUrl != null
|
fit: BoxFit.cover,
|
||||||
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
|
memCacheWidth: 280,
|
||||||
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
|
placeholder: (context, url) => Container(
|
||||||
),
|
width: 140,
|
||||||
const SizedBox(height: 6),
|
height: 140,
|
||||||
Expanded(
|
color: colorScheme.surfaceContainerHighest,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
],
|
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) {
|
void _navigateToAlbum(ArtistAlbum album) {
|
||||||
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
Navigator.push(context, MaterialPageRoute(
|
|
||||||
builder: (context) => AlbumScreen(
|
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
||||||
albumId: album.id,
|
Navigator.push(context, MaterialPageRoute(
|
||||||
albumName: album.name,
|
builder: (context) => ExtensionAlbumScreen(
|
||||||
coverUrl: album.coverUrl,
|
extensionId: album.providerId!,
|
||||||
// tracks: null - will be fetched in AlbumScreen
|
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) {
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
final isRateLimit = error.contains('429') ||
|
final isRateLimit = error.contains('429') ||
|
||||||
error.toLowerCase().contains('rate limit') ||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
@@ -366,7 +827,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.errorContainer,
|
color: colorScheme.errorContainer,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -378,7 +839,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Rate Limited',
|
context.l10n.errorRateLimited,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -386,7 +847,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Too many requests. Please wait a moment and try again.',
|
context.l10n.errorRateLimitedMessage,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -401,11 +862,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default error display
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -0,0 +1,576 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/// Screen to display downloaded tracks from a specific album
|
||||||
|
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||||
|
final String albumName;
|
||||||
|
final String artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
|
||||||
|
const DownloadedAlbumScreen({
|
||||||
|
super.key,
|
||||||
|
required this.albumName,
|
||||||
|
required this.artistName,
|
||||||
|
this.coverUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||||
|
// Multi-select state
|
||||||
|
bool _isSelectionMode = false;
|
||||||
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
|
/// Get tracks for this album from history provider (reactive)
|
||||||
|
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
||||||
|
return allItems.where((item) {
|
||||||
|
final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||||
|
final albumKey = '${widget.albumName}|${widget.artistName}';
|
||||||
|
return itemKey == albumKey;
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final aNum = a.trackNumber ?? 999;
|
||||||
|
final bNum = b.trackNumber ?? 999;
|
||||||
|
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||||
|
return a.trackName.compareTo(b.trackName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enterSelectionMode(String itemId) {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = true;
|
||||||
|
_selectedIds.add(itemId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exitSelectionMode() {
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = false;
|
||||||
|
_selectedIds.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSelection(String itemId) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedIds.contains(itemId)) {
|
||||||
|
_selectedIds.remove(itemId);
|
||||||
|
if (_selectedIds.isEmpty) {
|
||||||
|
_isSelectionMode = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_selectedIds.add(itemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectAll(List<DownloadHistoryItem> tracks) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.addAll(tracks.map((e) => e.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteSelected(List<DownloadHistoryItem> currentTracks) async {
|
||||||
|
final count = _selectedIds.length;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(context.l10n.downloadedAlbumDeleteSelected),
|
||||||
|
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(context.l10n.dialogCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: Text(context.l10n.dialogDelete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true && mounted) {
|
||||||
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||||
|
final idsToDelete = _selectedIds.toList();
|
||||||
|
|
||||||
|
int deletedCount = 0;
|
||||||
|
for (final id in idsToDelete) {
|
||||||
|
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||||
|
if (item != null) {
|
||||||
|
try {
|
||||||
|
final file = File(item.filePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
historyNotifier.removeFromHistory(id);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_exitSelectionMode();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openFile(String filePath) async {
|
||||||
|
try {
|
||||||
|
final mimeType = audioMimeTypeForPath(filePath);
|
||||||
|
await OpenFilex.open(filePath, type: mimeType);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||||
|
Navigator.push(context, PageRouteBuilder(
|
||||||
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
|
||||||
|
// Watch history and get tracks for this album (reactive!)
|
||||||
|
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
|
final tracks = _getAlbumTracks(allHistoryItems);
|
||||||
|
|
||||||
|
// Auto-pop if album has less than 2 tracks (no longer an "album")
|
||||||
|
if (tracks.length < 2) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
});
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up selected IDs that no longer exist
|
||||||
|
final validIds = tracks.map((t) => t.id).toSet();
|
||||||
|
_selectedIds.removeWhere((id) => !validIds.contains(id));
|
||||||
|
if (_selectedIds.isEmpty && _isSelectionMode) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) setState(() => _isSelectionMode = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_isSelectionMode,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (!didPop && _isSelectionMode) {
|
||||||
|
_exitSelectionMode();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, colorScheme, tracks),
|
||||||
|
_buildTrackListHeader(context, colorScheme, tracks),
|
||||||
|
_buildTrackList(context, colorScheme, tracks),
|
||||||
|
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom Selection Action Bar
|
||||||
|
AnimatedPositioned(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
||||||
|
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 280,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (widget.coverUrl != null)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
colorBlendMode: BlendMode.darken,
|
||||||
|
memCacheWidth: 600,
|
||||||
|
),
|
||||||
|
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],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||||
|
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.albumName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
widget.artistName,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
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.download_done, size: 14, color: colorScheme.onPrimaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(context.l10n.downloadedAlbumDownloadedCount(tracks.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (_getCommonQuality(tracks) != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||||
|
? colorScheme.tertiaryContainer
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_getCommonQuality(tracks)!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||||
|
? colorScheme.onTertiaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
|
||||||
|
if (tracks.isEmpty) return null;
|
||||||
|
final firstQuality = tracks.first.quality;
|
||||||
|
if (firstQuality == null) return null;
|
||||||
|
for (final track in tracks) {
|
||||||
|
if (track.quality != firstQuality) return null;
|
||||||
|
}
|
||||||
|
return firstQuality;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||||
|
const Spacer(),
|
||||||
|
if (!_isSelectionMode)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
||||||
|
icon: const Icon(Icons.checklist, size: 18),
|
||||||
|
label: Text(context.l10n.actionSelect),
|
||||||
|
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final track = tracks[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(track.id),
|
||||||
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: tracks.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) {
|
||||||
|
final isSelected = _selectedIds.contains(track.id);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
onTap: _isSelectionMode
|
||||||
|
? () => _toggleSelection(track.id)
|
||||||
|
: () => _navigateToMetadataScreen(track),
|
||||||
|
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
|
||||||
|
leading: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (_isSelectionMode) ...[
|
||||||
|
Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? colorScheme.primary : Colors.transparent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
|
||||||
|
),
|
||||||
|
child: isSelected
|
||||||
|
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
child: Text(
|
||||||
|
track.trackNumber?.toString() ?? '-',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
track.trackName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
track.artistName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
trailing: _isSelectionMode ? null : IconButton(
|
||||||
|
onPressed: () => _openFile(track.filePath),
|
||||||
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) {
|
||||||
|
final selectedCount = _selectedIds.length;
|
||||||
|
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.15),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, -4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: _exitSelectionMode,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
if (allSelected) {
|
||||||
|
_exitSelectionMode();
|
||||||
|
} else {
|
||||||
|
_selectAll(tracks);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
|
||||||
|
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: Text(
|
||||||
|
selectedCount > 0
|
||||||
|
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
|
||||||
|
: context.l10n.downloadedAlbumSelectToDelete,
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
|
||||||
|
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/track_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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 {
|
class HomeScreen extends ConsumerStatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
@@ -267,6 +269,23 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
|
|
||||||
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
|
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
|
||||||
final track = ref.watch(trackProvider).tracks[index];
|
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(
|
return ListTile(
|
||||||
leading: track.coverUrl != null
|
leading: track.coverUrl != null
|
||||||
? ClipRRect(
|
? ClipRRect(
|
||||||
@@ -285,22 +304,87 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
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),
|
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
track.artistName,
|
subtitleText,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
trailing: Text(
|
trailing: isCollection
|
||||||
_formatDuration(track.duration),
|
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
: Text(
|
||||||
color: colorScheme.onSurfaceVariant,
|
_formatDuration(track.duration),
|
||||||
),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
),
|
color: colorScheme.onSurfaceVariant,
|
||||||
onTap: () => _downloadTrack(index),
|
),
|
||||||
|
),
|
||||||
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||