Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b39ec41255 | |||
| 1407018d98 | |||
| 2092f078ec | |||
| 0dc89cf569 | |||
| 3c1e9d03a0 | |||
| 28a082f47a | |||
| 38994d5900 | |||
| 472896328a | |||
| 92f408035a | |||
| 979186243c | |||
| ee66247bea | |||
| 66a9daf733 | |||
| 69a9e0cb40 | |||
| cd6beaa7d4 | |||
| 5f4ff17630 | |||
| 3c3bbe516e | |||
| a1d1ab1f0f | |||
| ab9456fff8 | |||
| 2f673469aa | |||
| 05fde22075 | |||
| deab7b7dd6 | |||
| ae5da3b6e0 | |||
| 4d0c8f49aa | |||
| 3068f4e367 | |||
| 3844704490 | |||
| 12144b8220 | |||
| b639080494 | |||
| e67d7d68cb | |||
| b8f18c1cf5 | |||
| 529958c4af | |||
| 40077a577c | |||
| e0fbd706ce | |||
| b76879f204 | |||
| 564dd8bf95 | |||
| b317f7cd76 | |||
| a3b49d2642 | |||
| 6f20620c97 | |||
| b6a055a01a | |||
| 44ac593ddc | |||
| ca4c2a661e | |||
| 8b3b39f390 | |||
| 915934e5dd | |||
| 42f15018ae | |||
| 3554a7b5b9 | |||
| f2941939b7 | |||
| 1a77ded997 | |||
| 05d25d4d7c | |||
| 7cc1fef989 | |||
| abc599d7f9 | |||
| eefbb63299 | |||
| fdbb474763 | |||
| 6a7eef6956 | |||
| 9b27e86e0f | |||
| dbe8f5d814 | |||
| 9847594ca1 | |||
| 4a966e5e52 | |||
| d8ba4549aa | |||
| 986f5eafc8 | |||
| 84df64fcfe | |||
| a9150b85b9 | |||
| 68e6c8be35 | |||
| bd42655c0e | |||
| fe1c96ea12 | |||
| bae2bf63eb | |||
| 803e0dc5a3 | |||
| 474c37ec8e | |||
| eb7726263a | |||
| f87ccc51c5 | |||
| b0b4e7803c | |||
| 450f19c656 | |||
| 55b9c08f99 | |||
| a5f3aab775 | |||
| 7442c9b106 | |||
| ae66cb478b | |||
| 2516c3e618 | |||
| 02a5893279 | |||
| bd0d653210 | |||
| 62626ddc08 | |||
| b6574f0097 | |||
| c35a8dd803 | |||
| d54b2249b6 | |||
| f7be2c1e12 | |||
| 309568becc | |||
| dd9b6dbfe3 | |||
| 4692b48174 | |||
| db82fa3ae1 | |||
| 5c42507b12 | |||
| ebe7d87da7 | |||
| 9cd2b1d8c5 | |||
| 49f1fb43fa | |||
| 65b521ff8b | |||
| 6d578694e2 | |||
| f7ec649b24 | |||
| 71a9e1baef | |||
| 4a4adcb72e | |||
| 3458f03158 | |||
| 4fe4a01840 | |||
| e5d6fddeda | |||
| 370f5e3b8b | |||
| f5bb0820d5 | |||
| feb6da3ecb | |||
| 39f28a12aa | |||
| 416fc79637 | |||
| 1f43780bec | |||
| 481b4b03dc | |||
| b7fd2f7902 | |||
| f2e1e59d6a | |||
| 3af2ecf1f4 | |||
| 1b2f2c891c | |||
| 155f3259f2 | |||
| f52d8d68b8 | |||
| 216d6e152c | |||
| b6f90e727c | |||
| 790bbc544f | |||
| bd511f7dc6 | |||
| e91c8c28a8 | |||
| 3c6d1afa97 | |||
| 3947e109b4 | |||
| bf87662f99 | |||
| 4273edd836 | |||
| 7ce41fc1c1 | |||
| fb7a576e00 | |||
| 30a559b279 | |||
| f77d5fdf14 | |||
| 0a0667889c | |||
| 14d8cd54d7 | |||
| 5fa3d405e6 | |||
| 34eb335fd0 | |||
| c910530927 | |||
| 69e1a6cf6b | |||
| bd84613624 | |||
| 0b4777fc6b | |||
| e22813caec | |||
| 8f6e8432de | |||
| b3c98cecc3 | |||
| 49a18a977b | |||
| a5d0feeedf | |||
| a574e73b44 | |||
| a66f6a739f | |||
| cc7e1b54b6 | |||
| 28cb7fcd3d | |||
| aeb370beca | |||
| 239707e2da | |||
| c1e2778735 | |||
| fb608a554d | |||
| 7561065802 | |||
| 56c8d89999 | |||
| 9192760f3c | |||
| 40ec24db69 | |||
| ba8d0a3438 | |||
| 82decf99a6 | |||
| 6ba9fc1fec | |||
| 715d94c2ed | |||
| e1a722f479 | |||
| edbe12c512 | |||
| 9fc6542792 | |||
| 4c01ee26c2 | |||
| 813b9fcf61 | |||
| fe070e0177 | |||
| 423bb87ed8 | |||
| 1641f51b0c | |||
| 3f78a1f3d1 |
@@ -1,4 +1,3 @@
|
|||||||
github: zarzet
|
github: zarzet
|
||||||
ko_fi: zarzet
|
ko_fi: zarzet
|
||||||
buy_me_a_coffee: zarzet
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Deploy to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'site/**'
|
||||||
|
- '.github/workflows/pages.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: site
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.25"
|
go-version: "1.26"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
@@ -174,7 +174,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.25"
|
go-version: "1.26"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
|
|||||||
@@ -1,5 +1,200 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.6.7] - 2026-02-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- "Advanced Filename Templates" - new placeholders for custom track/disc formatting and date patterns
|
||||||
|
- `{track_raw}` and `{disc_raw}` - unpadded raw numbers
|
||||||
|
- `{track:N}` and `{disc:N}` - zero-padded to N digits (e.g. `{track:02}` → `01`)
|
||||||
|
- `{date}` - full release date from metadata
|
||||||
|
- `{date:%Y-%m-%d}` - date formatting with strftime patterns
|
||||||
|
- "Show advanced tags" toggle in Settings > Download > Filename Format to reveal these placeholders
|
||||||
|
- Low-RAM / ARM32-only device profiling - detects constrained devices at startup and reduces image cache (120 items / 24 MiB) and disables overscroll effects for smoother performance
|
||||||
|
- Responsive selection bar on artist screen - switches to compact stacked layout on narrow screens (< 430dp) or large text scale (> 1.15x)
|
||||||
|
- Quality picker dialog before downloading individual tracks from artist screen (when "Ask quality before download" is enabled)
|
||||||
|
- Project website with GitHub Pages deployment workflow
|
||||||
|
- Mobile burger menu navigation for all site pages
|
||||||
|
- Go filename template test suite
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed ICU plural syntax errors in DE, ES, PT, RU translations - incorrect `=1` clause was causing missing plural forms
|
||||||
|
- Fixed featured-artist regex incorrectly splitting on `&` character (e.g. "Simon & Garfunkel" was being split) - removed `&` from separator pattern
|
||||||
|
- Fixed `{date}` placeholder not working in filename templates - release date was not being passed to the template builder across all providers (Amazon, Qobuz, Tidal, YouTube, extensions)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved Go backend metadata handling - filename builder now supports fallback metadata keys and automatic type conversion for more robust template rendering
|
||||||
|
- Extension providers now pass full metadata set to filename builder (track, disc, year, date, release_date)
|
||||||
|
- Updated translations: added filename advanced tags strings (EN, ID), regenerated all locale dart files
|
||||||
|
- Updated app screenshot assets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.6.6] - 2026-02-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- "Filter Contributing Artists in Album Artist" setting - strips featured/contributing artists from Album Artist metadata tag
|
||||||
|
- Library scan notifications (Android and iOS) - shows progress, completion, failure, and cancellation status
|
||||||
|
- Collapsible "Artist Name Filters" section in download settings UI
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed downloads not working on iOS - missing `downloadByStrategy` and `downloadFromYouTube` method channel handlers in AppDelegate.swift
|
||||||
|
- Fixed extended metadata (genre, label, copyright) lost during service fallback (e.g. Tidal unavailable, falls back to Qobuz) - Go backend now enriches metadata from Deezer by ISRC before download and preserves it through the fallback chain
|
||||||
|
- Fixed local library showing incorrect "16-bit" quality label for lossy formats (MP3, Opus) - now displays actual bitrate (e.g. "MP3 320kbps")
|
||||||
|
- Fixed inaccurate Opus/Vorbis duration calculation (e.g. 4:11 showing as 8:44) - now reads granule position from last Ogg page for precise duration
|
||||||
|
- Fixed MP3 duration/bitrate inaccuracy for VBR files - added Xing/Info and VBRI header parsing with MPEG2/2.5 bitrate table support
|
||||||
|
- Fixed Track Metadata screen showing scan date instead of file date for local library items
|
||||||
|
- Fixed SAF content URI paths displayed as raw `content://` strings in Track Metadata - now shows human-readable paths
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed legacy iOS download handlers (`downloadTrack`, `downloadWithFallback`, `downloadFromYouTube`) - iOS now uses `downloadByStrategy` only
|
||||||
|
- Updated translations from Crowdin (all 14 languages)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.6.5] - 2026-02-10
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- **Audio Format Conversion**: Convert between FLAC, MP3, and Opus directly from Track Metadata screen with full metadata and cover art preservation
|
||||||
|
- **PC v7.0.8 Backend Merge**: Adopts several Go backend improvements from SpotiFLAC PC v7.0.8 including Amazon encrypted stream support, SpotFetch metadata fallback, and Qobuz API update
|
||||||
|
- **Amazon Music Re-enabled**: Amazon provider back in service with new API
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- "Use Primary Artist Only" setting: strips featured artists from folder names (e.g. "Justin Bieber, Quavo" becomes "Justin Bieber") for cleaner folder organization
|
||||||
|
- Supports separators: `, ` `;` `&` `feat.` `ft.` `featuring` `with` `x`
|
||||||
|
- Available in Settings > Download > below "Use Album Artist for folders"
|
||||||
|
- Audio format conversion from Track Metadata screen
|
||||||
|
- Convert between FLAC, MP3, and Opus formats (any direction)
|
||||||
|
- Selectable bitrate: 128k, 192k, 256k, 320k
|
||||||
|
- Full metadata and cover art preservation during conversion
|
||||||
|
- Confirmation dialog before converting (original file deleted after)
|
||||||
|
- SAF storage support: copies to temp, converts, writes back via SAF
|
||||||
|
- Download history automatically updated with new file path
|
||||||
|
- Unified download request contract (`DownloadRequestPayload`) for all providers/flows
|
||||||
|
- Includes full superset fields: lyrics mode, genre/label/copyright, provider IDs, SAF params, cover/quality settings
|
||||||
|
- Added strategy flags in payload: `use_extensions`, `use_fallback`
|
||||||
|
- New Go unified router entrypoint: `DownloadByStrategy(requestJSON)`
|
||||||
|
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||||
|
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||||
|
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||||
|
- New backend client for `spotify.afkarxyz.fun/api`
|
||||||
|
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||||
|
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||||
|
- Includes heuristic detection of lyrics stored in Comment fields
|
||||||
|
- Edit Metadata now supports manual cover selection (pick/replace cover image) and embeds it into audio tags on save
|
||||||
|
- Save Lyrics now shows an immediate in-progress snackbar (`Saving lyrics...`) so users know the operation has started
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Merged several Go backend improvements from SpotiFLAC PC v7.0.8: Amazon new API with encrypted stream/decryption support, SpotFetch metadata fallback for Spotify-blocked regions, multi-format lyrics extraction (MP3/Opus/OGG), Qobuz Jumo API update.
|
||||||
|
- Download queue execution now builds one payload and uses a single bridge entrypoint (`PlatformBridge.downloadByStrategy`) instead of branching into multiple bridge methods
|
||||||
|
- Dart `downloadByStrategy` now sends a single request to Go (`downloadByStrategy` channel); routing concern is centralized in Go backend
|
||||||
|
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||||
|
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||||
|
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||||
|
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||||
|
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||||
|
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||||
|
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||||
|
- Edit Metadata cover section moved to the top of the form and now previews current embedded cover before replacement (plus selected replacement preview)
|
||||||
|
- Edit Metadata cover preview enlarged (120px to 160px) with shadow, side-by-side layout for current vs selected cover, and label repositioned below image
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed lyrics mode "External .LRC" still embedding lyrics into metadata - `lyrics_mode` was not being sent to Go backend for single-service downloads and YouTube provider, causing Go to default to "embed"
|
||||||
|
- Fixed `flutter_local_notifications` v20 breaking changes - migrated all `initialize()`, `show()`, and `cancel()` calls from positional parameters to named parameters
|
||||||
|
- Fixed SAF duplicate folder bug: concurrent batch downloads creating empty folders with `(1)`, `(2)`, `(3)` suffixes - added synchronized lock to `ensureDocumentDir` in Kotlin with duplicate detection and cleanup
|
||||||
|
- Track Metadata lyrics section now hides "Embed Lyrics" when lyrics are already embedded in file, preventing redundant embed attempts
|
||||||
|
- Fixed lyrics embed path to support FLAC/MP3/Opus consistently (including SAF files) without forcing unsupported parser paths
|
||||||
|
- Inconsistent parameter parity across download paths
|
||||||
|
- `downloadWithExtensions` now carries `copyright`
|
||||||
|
- YouTube path now carries `embed_max_quality_cover` and metadata parity fields
|
||||||
|
- Inconsistent success response metadata between direct/fallback flows
|
||||||
|
- Added shared Go response builder for `DownloadTrack` and `DownloadWithFallback`
|
||||||
|
- Success responses now consistently include `genre`, `label`, `copyright`, and `lyrics_lrc`
|
||||||
|
- YouTube success response now also includes extended metadata fields (`cover_url`, `genre`, `label`, `copyright`) for parity with other providers
|
||||||
|
- Fixed `Save Lyrics` crash on Android (`java.lang.Integer cannot be cast to java.lang.Long`) by normalizing `duration_ms` channel argument as `Number -> Long`
|
||||||
|
- Fixed FLAC Re-enrich cover edge case where metadata could be written without cover when temp cover file creation failed; FLAC cover embed now uses in-memory bytes and verifies cover after write
|
||||||
|
- Fixed FLAC picture-block embed robustness by detecting image MIME via magic bytes (JPEG/PNG/GIF/WEBP) instead of relying on filename extension
|
||||||
|
- Fixed MP3/Opus metadata rewrite flows to preserve existing embedded cover when no new cover is available
|
||||||
|
- Fixed Library tab cover not updating after manual cover edit/re-embed for downloaded tracks
|
||||||
|
- Queue/Library now prefers embedded cover art extracted from local files (not just cached `coverUrl`)
|
||||||
|
- Added per-track extraction cache with file-modification invalidation so updated embedded art is reflected in Library
|
||||||
|
- Extraction is now on-demand for edited tracks only (not full-library reload)
|
||||||
|
- Returning from Track Metadata now refreshes cover cache only for the affected track
|
||||||
|
- Cover refresh is now skipped when file modification time is unchanged, removing unnecessary flash when simply opening/closing metadata screen
|
||||||
|
- Fixed repeated cover preview extraction in Track Metadata screen (`track_cover_preview_*`) causing visible flash when reopening
|
||||||
|
- Added in-memory preview cache keyed by file path so reopening metadata reuses existing preview without re-extract
|
||||||
|
- Cache validation uses file modification time for filesystem paths; SAF paths are refreshed only after successful edit actions
|
||||||
|
- Queue/Library now also compares SAF file last-modified (`getSafFileModTimes`) before refreshing embedded-cover cache
|
||||||
|
- Preview cache key is now stable per track item (not volatile temp SAF path), eliminating false cache misses on SAF-backed files
|
||||||
|
- Track Metadata no longer auto-extracts cover preview on every screen open; extraction now runs only after actual edit/re-enrich changes (or when explicitly forced)
|
||||||
|
- Track metadata edits/re-enrich now sync updated tags back into `downloadHistoryProvider` + SQLite history rows
|
||||||
|
- Non-Library screens that read download history (Home/album/history views) now reflect updated title/artist/album/tags without manual rescan
|
||||||
|
- Track Metadata back-navigation now returns an explicit update result after successful edits/re-enrich, enabling History-tab cover refresh fallback when SAF timestamps are unreliable
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Configured Flutter image cache limits (240 entries / 60 MiB) and added `ResizeImage` wrappers for cover art precaching across all screens, reducing peak memory usage on cover-heavy pages
|
||||||
|
- Added LRU eviction to Deezer cache with configurable max entries per cache type (search/album/artist/ISRC) and periodic expired-entry cleanup to prevent unbounded memory growth in long sessions
|
||||||
|
- Download progress notifications are now normalized (2-decimal progress, 1-decimal speed, 0.1 MiB byte steps) and deduplicated by track/artist/percent/queue-count, reducing notification overhead during batch downloads
|
||||||
|
- Each queue item now uses a dedicated `ConsumerWidget` with per-item `.select()` instead of rebuilding the entire list on any item change; items are wrapped in `RepaintBoundary` for paint isolation
|
||||||
|
- Queue/Library search indexes are now built on-demand per item instead of upfront for all items, with bounded LRU caches (max 4000 entries)
|
||||||
|
- `copyWith` now preserves derived lookup indexes (ISRC map, track key set) when items list is unchanged, avoiding O(n) rebuild on every scan progress update
|
||||||
|
- Scan progress polling now compares values before calling `setState`, skipping unnecessary widget rebuilds when nothing changed
|
||||||
|
- Added in-flight flag to download progress and library scan polling to prevent concurrent timer callbacks from overlapping
|
||||||
|
- New `DownloadedEmbeddedCoverResolver` service replaces per-screen cover extraction logic with a shared bounded cache (160 entries), mod-time validation, and throttled refresh checks
|
||||||
|
- Multiple embedded cover change callbacks are now coalesced into a single frame via `addPostFrameCallback`, preventing redundant rebuilds
|
||||||
|
- Downloaded album screen now caches filtered/sorted track lists and reuses them when the source data reference is unchanged
|
||||||
|
- Home tab recent downloads now use single-pass aggregation instead of building full per-album lists, and store only IDs instead of full item objects for the clear-all action
|
||||||
|
- Removed duplicate `_downloadedSpotifyIds` Set and `_isrcSet` (both now use existing map lookups), removed unused `_isTyping` state in home tab
|
||||||
|
- Track cache pre-warming is now capped at 80 tracks per request to avoid excessive backend calls on large playlists
|
||||||
|
- About page contributor avatars now use `memCacheWidth`/`memCacheHeight` to decode at display size instead of full resolution
|
||||||
|
- Orphaned download cleanup now checks file existence in parallel (chunk 16) instead of sequentially
|
||||||
|
- Local library `findByTrackAndArtist` now uses O(1) map lookup (`_byTrackKey`) instead of O(n) linear scan
|
||||||
|
- Local library database load and SharedPreferences fetch now run in parallel
|
||||||
|
- Legacy mod-time backfill now uses chunked parallel `File.stat` (chunk 24) with per-chunk cancel check
|
||||||
|
- Downloaded album screen now caches disc grouping, sorted disc numbers, common quality, and embedded cover path with reference-identity invalidation
|
||||||
|
- Local album screen common quality is now computed once during cache rebuild instead of per-build
|
||||||
|
- Batch delete in album screens now uses O(1) map lookup (`tracksById`) instead of `.where().firstOrNull`
|
||||||
|
- Cache management page now fires all async init calls in parallel and uses chunked async directory deletion (chunk 24)
|
||||||
|
- Cover resolver preview file existence check is now throttled (2.2s interval) to reduce synchronous I/O in build path
|
||||||
|
- History and library database DELETE operations are now chunked (500 per batch) to stay within SQLite variable limits
|
||||||
|
- Library database `cleanupMissingFiles` now checks file existence in parallel (chunk 16) and deletes in batched SQL
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- All logs (Go and Dart) now automatically redact Bearer tokens, access/refresh tokens, client secrets, API keys, and passwords using regex-based sanitization before storage
|
||||||
|
- Extension auth URLs are now validated for HTTPS-only, no embedded credentials, and no private/local network targets before opening
|
||||||
|
- Auth URLs in logs are summarized to scheme+host+path only (query params stripped) to prevent token leakage; token exchange error bodies are truncated and sanitized
|
||||||
|
- Extension HTTP requests now block URLs with embedded credentials (`user:pass@host`)
|
||||||
|
- Extension storage files changed from `0644` to `0600` (owner-only read/write)
|
||||||
|
- All SAF relative directory paths are now sanitized per-segment with `.`/`..` filtering; all user-provided file names pass through `sanitizeFilename()` before use
|
||||||
|
- Extension ID is sanitized before building download destination path
|
||||||
|
- Log export device info now shows Build ID and Security Patch level instead of masked Device ID
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Centralized request serialization in `PlatformBridge` via shared invoke helper and unified payload model
|
||||||
|
- Go strategy router normalizes incoming service casing before dispatch
|
||||||
|
- Extension runtime: `customSearch` now passes query/options via VM globals instead of string interpolation, preventing parser edge cases on certain devices
|
||||||
|
- Extension runtime: JS panic handler now logs full stack trace for easier debugging
|
||||||
|
- `DownloadQueueLookup` expanded with `byItemId` map and `itemIds` list for O(1) queue item access from UI
|
||||||
|
- Non-error/non-fatal log entries are now skipped entirely (not just hidden) when detailed logging is disabled, reducing buffer growth and Go log polling overhead
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Buy Me a Coffee references removed from donate page, FUNDING.yml, README, and all localization files (account suspended)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.6.0] - 2026-02-09
|
## [3.6.0] - 2026-02-09
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/dec9c96672ab80e6bf6b7a66786e612f5404446c341eb0311b4cc78fe10c96a1)
|
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -71,9 +71,9 @@ A: Some countries have restricted access to certain streaming service APIs. If d
|
|||||||
|
|
||||||
### Want to support SpotiFLAC-Mobile?
|
### Want to support SpotiFLAC-Mobile?
|
||||||
|
|
||||||
_If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going._
|
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
||||||
|
|
||||||
[](https://ko-fi.com/zarzet) <a href="https://www.buymeacoffee.com/zarzet" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 40px !important;width: 150px !important;" ></a>
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||||
private val safScanLock = Any()
|
private val safScanLock = Any()
|
||||||
|
private val safDirLock = Any()
|
||||||
private var safScanProgress = SafScanProgress()
|
private var safScanProgress = SafScanProgress()
|
||||||
@Volatile private var safScanCancel = false
|
@Volatile private var safScanCancel = false
|
||||||
@Volatile private var safScanActive = false
|
@Volatile private var safScanActive = false
|
||||||
@@ -299,27 +300,55 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
|
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||||
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
if (relativeDir.isBlank()) return ""
|
||||||
if (relativeDir.isBlank()) return current
|
return relativeDir
|
||||||
|
.split("/")
|
||||||
|
.map { sanitizeFilename(it) }
|
||||||
|
.filter { it.isNotBlank() && it != "." && it != ".." }
|
||||||
|
.joinToString("/")
|
||||||
|
}
|
||||||
|
|
||||||
val parts = relativeDir.split("/").filter { it.isNotBlank() }
|
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
||||||
for (part in parts) {
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
val existing = current.findFile(part)
|
if (safeRelativeDir.isBlank()) {
|
||||||
current = if (existing != null && existing.isDirectory) {
|
return DocumentFile.fromTreeUri(this, treeUri)
|
||||||
existing
|
}
|
||||||
} else {
|
|
||||||
current.createDirectory(part) ?: return null
|
// Synchronize to prevent concurrent downloads from creating duplicate
|
||||||
}
|
// directories with (1), (2) suffixes via SAF's auto-rename behavior.
|
||||||
|
synchronized(safDirLock) {
|
||||||
|
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
||||||
|
|
||||||
|
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||||
|
for (part in parts) {
|
||||||
|
val existing = current.findFile(part)
|
||||||
|
current = if (existing != null && existing.isDirectory) {
|
||||||
|
existing
|
||||||
|
} else {
|
||||||
|
val created = current.createDirectory(part) ?: return null
|
||||||
|
// SAF may auto-rename to "part (1)" if another thread just created it.
|
||||||
|
// Re-check: if the created name differs, delete it and use the original.
|
||||||
|
val createdName = created.name ?: part
|
||||||
|
if (createdName != part) {
|
||||||
|
// Another thread won the race; delete the duplicate and use theirs.
|
||||||
|
created.delete()
|
||||||
|
current.findFile(part) ?: return null
|
||||||
|
} else {
|
||||||
|
created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current
|
||||||
}
|
}
|
||||||
return current
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
||||||
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
||||||
if (relativeDir.isBlank()) return current
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
|
if (safeRelativeDir.isBlank()) return current
|
||||||
|
|
||||||
val parts = relativeDir.split("/").filter { it.isNotBlank() }
|
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||||
for (part in parts) {
|
for (part in parts) {
|
||||||
val existing = current.findFile(part)
|
val existing = current.findFile(part)
|
||||||
if (existing == null || !existing.isDirectory) return null
|
if (existing == null || !existing.isDirectory) return null
|
||||||
@@ -359,14 +388,21 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
obj.put("relative_dir", "")
|
obj.put("relative_dir", "")
|
||||||
return obj.toString()
|
return obj.toString()
|
||||||
}
|
}
|
||||||
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
|
val safeFileName = sanitizeFilename(fileName)
|
||||||
|
if (safeFileName.isBlank()) {
|
||||||
|
obj.put("uri", "")
|
||||||
|
obj.put("relative_dir", "")
|
||||||
|
return obj.toString()
|
||||||
|
}
|
||||||
|
|
||||||
val treeUri = Uri.parse(treeUriStr)
|
val treeUri = Uri.parse(treeUriStr)
|
||||||
val targetDir = findDocumentDir(treeUri, relativeDir)
|
val targetDir = findDocumentDir(treeUri, safeRelativeDir)
|
||||||
if (targetDir != null) {
|
if (targetDir != null) {
|
||||||
val direct = targetDir.findFile(fileName)
|
val direct = targetDir.findFile(safeFileName)
|
||||||
if (direct != null && direct.isFile) {
|
if (direct != null && direct.isFile) {
|
||||||
obj.put("uri", direct.uri.toString())
|
obj.put("uri", direct.uri.toString())
|
||||||
obj.put("relative_dir", relativeDir)
|
obj.put("relative_dir", safeRelativeDir)
|
||||||
return obj.toString()
|
return obj.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,7 +428,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val childPath = if (path.isBlank()) childName else "$path/$childName"
|
val childPath = if (path.isBlank()) childName else "$path/$childName"
|
||||||
queue.add(child to childPath)
|
queue.add(child to childPath)
|
||||||
} else if (child.isFile) {
|
} else if (child.isFile) {
|
||||||
if (child.name == fileName) {
|
if (child.name == safeFileName) {
|
||||||
obj.put("uri", child.uri.toString())
|
obj.put("uri", child.uri.toString())
|
||||||
obj.put("relative_dir", path)
|
obj.put("relative_dir", path)
|
||||||
return obj.toString()
|
return obj.toString()
|
||||||
@@ -408,7 +444,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||||
val provided = req.optString("saf_file_name", "")
|
val provided = req.optString("saf_file_name", "")
|
||||||
if (provided.isNotBlank()) return provided
|
if (provided.isNotBlank()) return sanitizeFilename(provided)
|
||||||
|
|
||||||
val trackName = req.optString("track_name", "track")
|
val trackName = req.optString("track_name", "track")
|
||||||
val artistName = req.optString("artist_name", "")
|
val artistName = req.optString("artist_name", "")
|
||||||
@@ -599,7 +635,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val treeUri = Uri.parse(treeUriStr)
|
val treeUri = Uri.parse(treeUriStr)
|
||||||
val relativeDir = req.optString("saf_relative_dir", "")
|
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||||
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||||
val mimeType = mimeTypeForExt(outputExt)
|
val mimeType = mimeTypeForExt(outputExt)
|
||||||
val fileName = buildSafFileName(req, outputExt)
|
val fileName = buildSafFileName(req, outputExt)
|
||||||
@@ -1276,20 +1312,11 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
"downloadTrack" -> {
|
"downloadByStrategy" -> {
|
||||||
val requestJson = call.arguments as String
|
val requestJson = call.arguments as String
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
handleSafDownload(requestJson) { json ->
|
handleSafDownload(requestJson) { json ->
|
||||||
Gobackend.downloadTrack(json)
|
Gobackend.downloadByStrategy(json)
|
||||||
}
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"downloadWithFallback" -> {
|
|
||||||
val requestJson = call.arguments as String
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
handleSafDownload(requestJson) { json ->
|
|
||||||
Gobackend.downloadWithFallback(json)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
@@ -1465,11 +1492,12 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"safCreateFromPath" -> {
|
"safCreateFromPath" -> {
|
||||||
val treeUriStr = call.argument<String>("tree_uri") ?: ""
|
val treeUriStr = call.argument<String>("tree_uri") ?: ""
|
||||||
val relativeDir = call.argument<String>("relative_dir") ?: ""
|
val relativeDir = call.argument<String>("relative_dir") ?: ""
|
||||||
val fileName = call.argument<String>("file_name") ?: ""
|
val fileName = sanitizeFilename(call.argument<String>("file_name") ?: "")
|
||||||
val mimeType = call.argument<String>("mime_type") ?: "application/octet-stream"
|
val mimeType = call.argument<String>("mime_type") ?: "application/octet-stream"
|
||||||
val srcPath = call.argument<String>("src_path") ?: ""
|
val srcPath = call.argument<String>("src_path") ?: ""
|
||||||
val createdUri = withContext(Dispatchers.IO) {
|
val createdUri = withContext(Dispatchers.IO) {
|
||||||
if (treeUriStr.isBlank()) return@withContext null
|
if (treeUriStr.isBlank()) return@withContext null
|
||||||
|
if (fileName.isBlank()) return@withContext null
|
||||||
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
|
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
|
||||||
val existing = dir.findFile(fileName)
|
val existing = dir.findFile(fileName)
|
||||||
val createdNew = existing == null
|
val createdNew = existing == null
|
||||||
@@ -1716,7 +1744,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val durationMs = call.argument<Long>("duration_ms") ?: 0L
|
val durationMs = call.argument<Number>("duration_ms")?.toLong() ?: 0L
|
||||||
val outputPath = call.argument<String>("output_path") ?: ""
|
val outputPath = call.argument<String>("output_path") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
@@ -2093,24 +2121,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
"downloadWithExtensions" -> {
|
|
||||||
val requestJson = call.arguments as String
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
handleSafDownload(requestJson) { json ->
|
|
||||||
Gobackend.downloadWithExtensionsJSON(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"downloadFromYouTube" -> {
|
|
||||||
val requestJson = call.arguments as String
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
handleSafDownload(requestJson) { json ->
|
|
||||||
Gobackend.downloadFromYouTube(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"enrichTrackWithExtension" -> {
|
"enrichTrackWithExtension" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
val trackJson = call.argument<String>("track") ?: "{}"
|
val trackJson = call.argument<String>("track") ?: "{}"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 122 KiB |
@@ -31,6 +31,8 @@ type AmazonDownloader struct {
|
|||||||
var (
|
var (
|
||||||
globalAmazonDownloader *AmazonDownloader
|
globalAmazonDownloader *AmazonDownloader
|
||||||
amazonDownloaderOnce sync.Once
|
amazonDownloaderOnce sync.Once
|
||||||
|
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
|
||||||
|
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// AfkarXYZResponse is the response from AfkarXYZ API
|
// AfkarXYZResponse is the response from AfkarXYZ API
|
||||||
@@ -43,6 +45,12 @@ type AfkarXYZResponse struct {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin}
|
||||||
|
type AmazonStreamResponse struct {
|
||||||
|
StreamURL string `json:"streamUrl"`
|
||||||
|
DecryptionKey string `json:"decryptionKey"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
amazonDownloaderOnce.Do(func() {
|
amazonDownloaderOnce.Do(func() {
|
||||||
globalAmazonDownloader = &AmazonDownloader{
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
@@ -52,10 +60,9 @@ func NewAmazonDownloader() *AmazonDownloader {
|
|||||||
return globalAmazonDownloader
|
return globalAmazonDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks
|
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
|
||||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) {
|
// Returns downloadURL, suggested fileName, optional decryptionKey.
|
||||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
@@ -64,66 +71,184 @@ func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, st
|
|||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL)
|
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return downloadURL, fileName, nil
|
return downloadURL, fileName, decryptionKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
errStr := err.Error()
|
errStr := strings.ToLower(err.Error())
|
||||||
|
|
||||||
// Check if error is retryable
|
// Check if error is retryable
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||||
strings.Contains(errStr, "connection reset") ||
|
strings.Contains(errStr, "connection reset") ||
|
||||||
strings.Contains(errStr, "connection refused") ||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
strings.Contains(errStr, "EOF") ||
|
strings.Contains(errStr, "eof") ||
|
||||||
strings.Contains(errStr, "status 5") ||
|
strings.Contains(errStr, "status 5") ||
|
||||||
strings.Contains(errStr, "status 429")
|
strings.Contains(errStr, "status 429") ||
|
||||||
|
strings.Contains(errStr, "http 429")
|
||||||
|
|
||||||
if !isRetryable {
|
if !isRetryable {
|
||||||
return "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doAfkarXYZRequest performs a single request to AfkarXYZ API
|
func normalizeAmazonASIN(candidate string) string {
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) {
|
trimmed := strings.TrimSpace(candidate)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded, err := url.QueryUnescape(trimmed); err == nil {
|
||||||
|
trimmed = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed = strings.ToUpper(trimmed)
|
||||||
|
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
|
||||||
|
trimmed = trimmed[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if amazonASINRegex.MatchString(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAmazonASIN(amazonURL string) string {
|
||||||
|
raw := strings.TrimSpace(amazonURL)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err == nil {
|
||||||
|
query := parsed.Query()
|
||||||
|
|
||||||
|
// Prefer track-level ASIN when URL also contains albumAsin.
|
||||||
|
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
|
||||||
|
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
|
||||||
|
return asin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.Trim(parsed.Path, "/")
|
||||||
|
if path != "" {
|
||||||
|
segments := strings.Split(path, "/")
|
||||||
|
|
||||||
|
for i := 0; i < len(segments)-1; i++ {
|
||||||
|
segment := strings.ToLower(strings.TrimSpace(segments[i]))
|
||||||
|
if segment == "track" || segment == "tracks" {
|
||||||
|
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
|
||||||
|
return asin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
|
||||||
|
return asin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
|
||||||
|
return normalizeAmazonASIN(match)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doAfkarXYZRequest performs a single request to Amazon API.
|
||||||
|
// It tries new endpoint first, then falls back to legacy /convert endpoint.
|
||||||
|
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
|
||||||
|
asin := extractAmazonASIN(amazonURL)
|
||||||
|
if asin != "" {
|
||||||
|
GoLog("[Amazon] Using ASIN: %s\n", asin)
|
||||||
|
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
|
||||||
|
if err == nil {
|
||||||
|
return downloadURL, fileName, decryptKey, nil
|
||||||
|
}
|
||||||
|
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
|
||||||
|
}
|
||||||
|
return a.doAfkarXYZRequestLegacy(amazonURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp AmazonStreamResponse
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(apiResp.StreamURL) == "" {
|
||||||
|
return "", "", "", fmt.Errorf("Amazon API returned empty stream URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := asin + ".m4a"
|
||||||
|
return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
|
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
|
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to read response: %w", err)
|
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiResp AfkarXYZResponse
|
var apiResp AfkarXYZResponse
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
return "", "", fmt.Errorf("failed to decode response: %w", err)
|
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !apiResp.Success || apiResp.Data.DirectLink == "" {
|
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
|
||||||
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
|
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
|
||||||
}
|
}
|
||||||
|
|
||||||
fileName := apiResp.Data.FileName
|
fileName := apiResp.Data.FileName
|
||||||
@@ -134,19 +259,22 @@ func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, err
|
|||||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||||
fileName = reg.ReplaceAllString(fileName, "")
|
fileName = reg.ReplaceAllString(fileName, "")
|
||||||
|
|
||||||
return apiResp.Data.DirectLink, fileName, nil
|
return apiResp.Data.DirectLink, fileName, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
|
||||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||||
|
|
||||||
downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL)
|
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decryptionKey != "" {
|
||||||
|
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
|
||||||
|
}
|
||||||
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
||||||
return downloadURL, fileName, nil
|
return downloadURL, fileName, decryptionKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||||
@@ -233,17 +361,18 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD
|
|||||||
|
|
||||||
// AmazonDownloadResult contains download result with quality info
|
// AmazonDownloadResult contains download result with quality info
|
||||||
type AmazonDownloadResult struct {
|
type AmazonDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
Album string
|
Album string
|
||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
LyricsLRC string
|
LyricsLRC string
|
||||||
|
DecryptionKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
@@ -299,7 +428,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download using AfkarXYZ API
|
// Download using AfkarXYZ API
|
||||||
downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
||||||
}
|
}
|
||||||
@@ -312,6 +441,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
})
|
})
|
||||||
var outputPath string
|
var outputPath string
|
||||||
@@ -321,7 +451,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
filename = sanitizeFilename(filename) + ".flac"
|
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
|
||||||
|
if outputExt == "" {
|
||||||
|
outputExt = ".flac"
|
||||||
|
}
|
||||||
|
filename = sanitizeFilename(filename) + outputExt
|
||||||
outputPath = filepath.Join(req.OutputDir, filename)
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
@@ -352,6 +486,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actualOutputPath := outputPath
|
||||||
|
needsDecryption := strings.TrimSpace(decryptionKey) != ""
|
||||||
|
if needsDecryption {
|
||||||
|
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
@@ -360,7 +500,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
|
||||||
actualTrackNum := req.TrackNumber
|
actualTrackNum := req.TrackNumber
|
||||||
actualDiscNum := req.DiscNumber
|
actualDiscNum := req.DiscNumber
|
||||||
actualDate := req.ReleaseDate
|
actualDate := req.ReleaseDate
|
||||||
@@ -368,25 +507,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
actualTitle := req.TrackName
|
actualTitle := req.TrackName
|
||||||
actualArtist := req.ArtistName
|
actualArtist := req.ArtistName
|
||||||
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
if !needsDecryption {
|
||||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
existingMeta, metaErr := ReadMetadata(actualOutputPath)
|
||||||
actualTrackNum = existingMeta.TrackNumber
|
if metaErr == nil && existingMeta != nil {
|
||||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||||
|
actualTrackNum = existingMeta.TrackNumber
|
||||||
|
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||||
|
}
|
||||||
|
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||||
|
actualDiscNum = existingMeta.DiscNumber
|
||||||
|
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||||
|
}
|
||||||
|
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
||||||
|
actualDate = existingMeta.Date
|
||||||
|
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
||||||
|
}
|
||||||
|
if existingMeta.Album != "" && req.AlbumName == "" {
|
||||||
|
actualAlbum = existingMeta.Album
|
||||||
|
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
||||||
|
}
|
||||||
|
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
||||||
|
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
||||||
}
|
}
|
||||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
|
||||||
actualDiscNum = existingMeta.DiscNumber
|
|
||||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
|
||||||
}
|
|
||||||
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
|
||||||
actualDate = existingMeta.Date
|
|
||||||
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
|
||||||
}
|
|
||||||
if existingMeta.Album != "" && req.AlbumName == "" {
|
|
||||||
actualAlbum = existingMeta.Album
|
|
||||||
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
|
||||||
}
|
|
||||||
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
|
||||||
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
@@ -409,7 +551,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
} else {
|
} else {
|
||||||
existingCover, coverErr := ExtractCoverArt(outputPath)
|
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
|
||||||
if coverErr == nil && len(existingCover) > 0 {
|
if coverErr == nil && len(existingCover) > 0 {
|
||||||
coverData = existingCover
|
coverData = existingCover
|
||||||
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
||||||
@@ -418,11 +560,16 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isSafOutput {
|
if isSafOutput || needsDecryption {
|
||||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||||
} else {
|
} else {
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
|
||||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
if isFlacOutput {
|
||||||
|
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||||
|
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
@@ -433,20 +580,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
if lyricsMode == "external" || lyricsMode == "both" {
|
||||||
GoLog("[Amazon] Saving external LRC file...\n")
|
GoLog("[Amazon] Saving external LRC file...\n")
|
||||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
|
||||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||||
}
|
}
|
||||||
|
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
|
||||||
|
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
|
||||||
}
|
}
|
||||||
} else if req.EmbedLyrics {
|
} else if req.EmbedLyrics {
|
||||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||||
@@ -456,17 +605,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||||
|
|
||||||
quality := AudioQuality{}
|
quality := AudioQuality{}
|
||||||
if isSafOutput {
|
if isSafOutput || needsDecryption {
|
||||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
||||||
} else {
|
} else {
|
||||||
quality, err = GetAudioQuality(outputPath)
|
quality, err = GetAudioQuality(actualOutputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
|
||||||
if metaReadErr == nil && finalMeta != nil {
|
if metaReadErr == nil && finalMeta != nil {
|
||||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||||
@@ -478,9 +627,10 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to ISRC index for fast duplicate checking
|
// Add to ISRC index for fast duplicate checking.
|
||||||
if !isSafOutput {
|
// When decryption is pending in Flutter, postpone indexing until final file is settled.
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
if !isSafOutput && !needsDecryption {
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
bitDepth := 0
|
bitDepth := 0
|
||||||
@@ -496,16 +646,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return AmazonDownloadResult{
|
return AmazonDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: bitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
ReleaseDate: req.ReleaseDate,
|
ReleaseDate: req.ReleaseDate,
|
||||||
TrackNumber: actualTrackNum,
|
TrackNumber: actualTrackNum,
|
||||||
DiscNumber: actualDiscNum,
|
DiscNumber: actualDiscNum,
|
||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
LyricsLRC: lyricsLRC,
|
LyricsLRC: lyricsLRC,
|
||||||
|
DecryptionKey: decryptionKey,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractAmazonASIN(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "prefers trackAsin over albumAsin",
|
||||||
|
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
|
||||||
|
want: "B0TRACK456",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extract from tracks path",
|
||||||
|
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
|
||||||
|
want: "B0CYQHGWZJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extract from plain query asin",
|
||||||
|
url: "https://example.com/?asin=B0CYQHGWZJ",
|
||||||
|
want: "B0CYQHGWZJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback regex",
|
||||||
|
url: "https://example.com/path/B0CYQHGWZJ",
|
||||||
|
want: "B0CYQHGWZJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid url",
|
||||||
|
url: "https://music.amazon.com/tracks/not-valid",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := extractAmazonASIN(tt.url)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ type AudioMetadata struct {
|
|||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
|
Lyrics string
|
||||||
Label string
|
Label string
|
||||||
Copyright string
|
Copyright string
|
||||||
Composer string
|
Composer string
|
||||||
@@ -42,6 +43,7 @@ type OggQuality struct {
|
|||||||
SampleRate int
|
SampleRate int
|
||||||
BitDepth int
|
BitDepth int
|
||||||
Duration int
|
Duration int
|
||||||
|
Bitrate int // estimated bitrate in bps
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -181,6 +183,15 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
|||||||
metadata.Label = value
|
metadata.Label = value
|
||||||
case "TCR":
|
case "TCR":
|
||||||
metadata.Copyright = value
|
metadata.Copyright = value
|
||||||
|
case "ULT":
|
||||||
|
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = v
|
||||||
|
}
|
||||||
|
case "TXX":
|
||||||
|
desc, userValue := extractUserTextFrame(frameData)
|
||||||
|
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = userValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pos += 6 + frameSize
|
pos += 6 + frameSize
|
||||||
@@ -297,6 +308,15 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
|||||||
if v := extractCommentFrame(frameData); v != "" {
|
if v := extractCommentFrame(frameData); v != "" {
|
||||||
metadata.Comment = v
|
metadata.Comment = v
|
||||||
}
|
}
|
||||||
|
case "USLT":
|
||||||
|
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = v
|
||||||
|
}
|
||||||
|
case "TXXX":
|
||||||
|
desc, userValue := extractUserTextFrame(frameData)
|
||||||
|
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = userValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pos += 10 + frameSize
|
pos += 10 + frameSize
|
||||||
@@ -399,6 +419,98 @@ func extractCommentFrame(data []byte) string {
|
|||||||
return extractTextFrame(framed)
|
return extractTextFrame(framed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractLyricsFrame parses ID3 unsynchronized lyrics frames (USLT/ULT).
|
||||||
|
// Format: encoding(1) + language(3) + description(null-terminated) + lyrics text.
|
||||||
|
func extractLyricsFrame(data []byte) string {
|
||||||
|
if len(data) < 5 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
encoding := data[0]
|
||||||
|
rest := data[4:] // skip 3-byte language code
|
||||||
|
|
||||||
|
var text []byte
|
||||||
|
switch encoding {
|
||||||
|
case 1, 2: // UTF-16 variants use double-null terminator
|
||||||
|
for i := 0; i+1 < len(rest); i += 2 {
|
||||||
|
if rest[i] == 0 && rest[i+1] == 0 {
|
||||||
|
text = rest[i+2:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: // ISO-8859-1 or UTF-8
|
||||||
|
idx := bytes.IndexByte(rest, 0)
|
||||||
|
if idx >= 0 && idx+1 < len(rest) {
|
||||||
|
text = rest[idx+1:]
|
||||||
|
} else {
|
||||||
|
text = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(text) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
framed := make([]byte, 1+len(text))
|
||||||
|
framed[0] = encoding
|
||||||
|
copy(framed[1:], text)
|
||||||
|
return extractTextFrame(framed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
|
||||||
|
// encoding(1) + description + separator + value.
|
||||||
|
func extractUserTextFrame(data []byte) (string, string) {
|
||||||
|
if len(data) < 2 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
encoding := data[0]
|
||||||
|
payload := data[1:]
|
||||||
|
|
||||||
|
var descRaw, valueRaw []byte
|
||||||
|
switch encoding {
|
||||||
|
case 1, 2: // UTF-16 variants
|
||||||
|
for i := 0; i+1 < len(payload); i += 2 {
|
||||||
|
if payload[i] == 0 && payload[i+1] == 0 {
|
||||||
|
descRaw = payload[:i]
|
||||||
|
valueRaw = payload[i+2:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: // ISO-8859-1 or UTF-8
|
||||||
|
idx := bytes.IndexByte(payload, 0)
|
||||||
|
if idx >= 0 {
|
||||||
|
descRaw = payload[:idx]
|
||||||
|
if idx+1 <= len(payload) {
|
||||||
|
valueRaw = payload[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(valueRaw) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
descFramed := make([]byte, 1+len(descRaw))
|
||||||
|
descFramed[0] = encoding
|
||||||
|
copy(descFramed[1:], descRaw)
|
||||||
|
|
||||||
|
valueFramed := make([]byte, 1+len(valueRaw))
|
||||||
|
valueFramed[0] = encoding
|
||||||
|
copy(valueFramed[1:], valueRaw)
|
||||||
|
|
||||||
|
return strings.TrimSpace(extractTextFrame(descFramed)), strings.TrimSpace(extractTextFrame(valueFramed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLyricsDescription(description string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(description)) {
|
||||||
|
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func decodeUTF16(data []byte) string {
|
func decodeUTF16(data []byte) string {
|
||||||
if len(data) < 2 {
|
if len(data) < 2 {
|
||||||
return ""
|
return ""
|
||||||
@@ -553,50 +665,144 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
|
|
||||||
file.Seek(audioStart, io.SeekStart)
|
file.Seek(audioStart, io.SeekStart)
|
||||||
|
|
||||||
|
// Find first valid MP3 frame sync
|
||||||
frameHeader := make([]byte, 4)
|
frameHeader := make([]byte, 4)
|
||||||
for i := 0; i < 10000; i++ { // Search first 10KB
|
var frameStart int64 = -1
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
if _, err := io.ReadFull(file, frameHeader); err != nil {
|
if _, err := io.ReadFull(file, frameHeader); err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 {
|
if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 {
|
||||||
version := (frameHeader[1] >> 3) & 0x03
|
pos, _ := file.Seek(0, io.SeekCurrent)
|
||||||
layer := (frameHeader[1] >> 1) & 0x03
|
frameStart = pos - 4
|
||||||
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
|
|
||||||
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
|
|
||||||
|
|
||||||
sampleRates := [][]int{
|
|
||||||
{11025, 12000, 8000},
|
|
||||||
{0, 0, 0},
|
|
||||||
{22050, 24000, 16000},
|
|
||||||
{44100, 48000, 32000},
|
|
||||||
}
|
|
||||||
if version < 4 && sampleRateIdx < 3 {
|
|
||||||
quality.SampleRate = sampleRates[version][sampleRateIdx]
|
|
||||||
}
|
|
||||||
|
|
||||||
if version == 3 && layer == 1 {
|
|
||||||
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
|
|
||||||
if bitrateIdx < 16 {
|
|
||||||
quality.Bitrate = bitrates[bitrateIdx] * 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
quality.BitDepth = 16
|
|
||||||
|
|
||||||
if quality.Bitrate > 0 {
|
|
||||||
audioSize := fileSize - audioStart - 128
|
|
||||||
if audioSize > 0 {
|
|
||||||
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
file.Seek(-3, io.SeekCurrent)
|
file.Seek(-3, io.SeekCurrent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if frameStart < 0 {
|
||||||
|
return quality, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
version := (frameHeader[1] >> 3) & 0x03
|
||||||
|
layer := (frameHeader[1] >> 1) & 0x03
|
||||||
|
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
|
||||||
|
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
|
||||||
|
channelMode := (frameHeader[3] >> 6) & 0x03
|
||||||
|
|
||||||
|
// Sample rate tables: [version][index]
|
||||||
|
// version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1
|
||||||
|
sampleRates := [][]int{
|
||||||
|
{11025, 12000, 8000},
|
||||||
|
{0, 0, 0},
|
||||||
|
{22050, 24000, 16000},
|
||||||
|
{44100, 48000, 32000},
|
||||||
|
}
|
||||||
|
if version < 4 && sampleRateIdx < 3 {
|
||||||
|
quality.SampleRate = sampleRates[version][sampleRateIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bitrate tables for all MPEG versions and layers
|
||||||
|
// MPEG1 Layer III
|
||||||
|
if version == 3 && layer == 1 {
|
||||||
|
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
|
||||||
|
if bitrateIdx < 16 {
|
||||||
|
quality.Bitrate = bitrates[bitrateIdx] * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// MPEG2/2.5 Layer III
|
||||||
|
if (version == 0 || version == 2) && layer == 1 {
|
||||||
|
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
|
||||||
|
if bitrateIdx < 16 {
|
||||||
|
quality.Bitrate = bitrates[bitrateIdx] * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine samples per frame for duration calculation
|
||||||
|
samplesPerFrame := 1152 // MPEG1 Layer III
|
||||||
|
if version == 0 || version == 2 {
|
||||||
|
samplesPerFrame = 576 // MPEG2/2.5 Layer III
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read Xing/VBRI header from the first frame for VBR info
|
||||||
|
// Xing header offset depends on MPEG version and channel mode
|
||||||
|
var xingOffset int
|
||||||
|
if version == 3 { // MPEG1
|
||||||
|
if channelMode == 3 { // Mono
|
||||||
|
xingOffset = 17
|
||||||
|
} else {
|
||||||
|
xingOffset = 32
|
||||||
|
}
|
||||||
|
} else { // MPEG2/2.5
|
||||||
|
if channelMode == 3 {
|
||||||
|
xingOffset = 9
|
||||||
|
} else {
|
||||||
|
xingOffset = 17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read enough of the first frame to find Xing/VBRI header
|
||||||
|
xingBuf := make([]byte, 200)
|
||||||
|
file.Seek(frameStart+4, io.SeekStart)
|
||||||
|
n, _ := io.ReadFull(file, xingBuf)
|
||||||
|
xingBuf = xingBuf[:n]
|
||||||
|
|
||||||
|
vbrFrames := 0
|
||||||
|
vbrBytes := int64(0)
|
||||||
|
isVBR := false
|
||||||
|
|
||||||
|
// Check for Xing/Info header
|
||||||
|
if xingOffset+8 <= n {
|
||||||
|
tag := string(xingBuf[xingOffset : xingOffset+4])
|
||||||
|
if tag == "Xing" || tag == "Info" {
|
||||||
|
flags := binary.BigEndian.Uint32(xingBuf[xingOffset+4 : xingOffset+8])
|
||||||
|
off := xingOffset + 8
|
||||||
|
if flags&0x01 != 0 && off+4 <= n { // Frames flag
|
||||||
|
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[off : off+4]))
|
||||||
|
off += 4
|
||||||
|
}
|
||||||
|
if flags&0x02 != 0 && off+4 <= n { // Bytes flag
|
||||||
|
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[off : off+4]))
|
||||||
|
}
|
||||||
|
if vbrFrames > 0 {
|
||||||
|
isVBR = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for VBRI header (always at offset 32 from frame start + 4)
|
||||||
|
if !isVBR && 36+26 <= n {
|
||||||
|
if string(xingBuf[32:36]) == "VBRI" {
|
||||||
|
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
|
||||||
|
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[36+10 : 36+14]))
|
||||||
|
if vbrFrames > 0 {
|
||||||
|
isVBR = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
|
||||||
|
// Accurate duration from total frames
|
||||||
|
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
|
||||||
|
quality.Duration = int(totalSamples / int64(quality.SampleRate))
|
||||||
|
|
||||||
|
// Accurate average bitrate
|
||||||
|
if vbrBytes > 0 && quality.Duration > 0 {
|
||||||
|
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
|
||||||
|
} else if quality.Duration > 0 {
|
||||||
|
audioSize := fileSize - audioStart
|
||||||
|
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
|
||||||
|
}
|
||||||
|
} else if quality.Bitrate > 0 {
|
||||||
|
// CBR fallback: estimate duration from file size and frame bitrate
|
||||||
|
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
|
||||||
|
if audioSize > 0 {
|
||||||
|
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return quality, nil
|
return quality, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,9 +1006,16 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if commentLen > 10000 {
|
remaining := uint32(reader.Len())
|
||||||
|
if commentLen > remaining {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// Large comment entries are typically METADATA_BLOCK_PICTURE.
|
||||||
|
// Skip them so we can continue parsing normal text tags after/before.
|
||||||
|
if commentLen > 512*1024 {
|
||||||
|
reader.Seek(int64(commentLen), io.SeekCurrent)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
comment := make([]byte, commentLen)
|
comment := make([]byte, commentLen)
|
||||||
if _, err := reader.Read(comment); err != nil {
|
if _, err := reader.Read(comment); err != nil {
|
||||||
@@ -843,6 +1056,10 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
metadata.Composer = value
|
metadata.Composer = value
|
||||||
case "COMMENT", "DESCRIPTION":
|
case "COMMENT", "DESCRIPTION":
|
||||||
metadata.Comment = value
|
metadata.Comment = value
|
||||||
|
case "LYRICS", "UNSYNCEDLYRICS":
|
||||||
|
if metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = value
|
||||||
|
}
|
||||||
case "ORGANIZATION", "LABEL", "PUBLISHER":
|
case "ORGANIZATION", "LABEL", "PUBLISHER":
|
||||||
metadata.Label = value
|
metadata.Label = value
|
||||||
case "COPYRIGHT":
|
case "COPYRIGHT":
|
||||||
@@ -859,7 +1076,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
|||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
quality := &OggQuality{}
|
quality := &OggQuality{}
|
||||||
isOpus := false
|
|
||||||
|
|
||||||
packets, err := collectOggPackets(file, 5, 10)
|
packets, err := collectOggPackets(file, 5, 10)
|
||||||
if err != nil && len(packets) == 0 {
|
if err != nil && len(packets) == 0 {
|
||||||
@@ -875,15 +1091,17 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if streamType == oggStreamOpus {
|
isOpus := streamType == oggStreamOpus
|
||||||
isOpus = true
|
var preSkip int
|
||||||
|
|
||||||
|
if isOpus {
|
||||||
for _, pkt := range packets {
|
for _, pkt := range packets {
|
||||||
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
|
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
|
||||||
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
||||||
if quality.SampleRate == 0 {
|
if quality.SampleRate == 0 {
|
||||||
quality.SampleRate = 48000
|
quality.SampleRate = 48000
|
||||||
}
|
}
|
||||||
quality.BitDepth = 16
|
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -891,26 +1109,76 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
|||||||
for _, pkt := range packets {
|
for _, pkt := range packets {
|
||||||
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
|
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
|
||||||
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
||||||
quality.BitDepth = 16
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read granule position from the last Ogg page for accurate duration
|
||||||
stat, err := file.Stat()
|
stat, err := file.Stat()
|
||||||
if err == nil {
|
if err != nil {
|
||||||
// Very rough duration estimate based on file size
|
return quality, nil
|
||||||
// Assume ~128kbps average for Opus, ~160kbps for Vorbis
|
}
|
||||||
avgBitrate := 128000
|
fileSize := stat.Size()
|
||||||
if !isOpus {
|
|
||||||
avgBitrate = 160000
|
granule := readLastOggGranulePosition(file, fileSize)
|
||||||
|
if granule > 0 {
|
||||||
|
if isOpus {
|
||||||
|
// Opus always uses 48kHz granule position internally
|
||||||
|
totalSamples := granule - int64(preSkip)
|
||||||
|
if totalSamples > 0 {
|
||||||
|
quality.Duration = int(totalSamples / 48000)
|
||||||
|
}
|
||||||
|
} else if quality.SampleRate > 0 {
|
||||||
|
quality.Duration = int(granule / int64(quality.SampleRate))
|
||||||
}
|
}
|
||||||
quality.Duration = int(stat.Size() * 8 / int64(avgBitrate))
|
}
|
||||||
|
|
||||||
|
// Calculate average bitrate from file size and actual duration
|
||||||
|
if quality.Duration > 0 {
|
||||||
|
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
||||||
}
|
}
|
||||||
|
|
||||||
return quality, nil
|
return quality, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readLastOggGranulePosition seeks to the end of the file and scans backwards
|
||||||
|
// to find the last Ogg page, then reads its granule position (bytes 6-13).
|
||||||
|
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||||
|
// Read the last chunk of the file to find the last OggS sync
|
||||||
|
searchSize := int64(65536)
|
||||||
|
if searchSize > fileSize {
|
||||||
|
searchSize = fileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, searchSize)
|
||||||
|
offset := fileSize - searchSize
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
n, err := file.ReadAt(buf, offset)
|
||||||
|
if err != nil && n == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
buf = buf[:n]
|
||||||
|
|
||||||
|
// Scan backwards for "OggS" magic
|
||||||
|
lastPageOffset := -1
|
||||||
|
for i := n - 4; i >= 0; i-- {
|
||||||
|
if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' {
|
||||||
|
lastPageOffset = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastPageOffset < 0 || lastPageOffset+14 > n {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64)
|
||||||
|
return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14]))
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// ID3v1 Genre List
|
// ID3v1 Genre List
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -28,15 +28,23 @@ const (
|
|||||||
deezerAPITimeoutMobile = 25 * time.Second
|
deezerAPITimeoutMobile = 25 * time.Second
|
||||||
deezerMaxRetries = 2
|
deezerMaxRetries = 2
|
||||||
deezerRetryDelay = 500 * time.Millisecond
|
deezerRetryDelay = 500 * time.Millisecond
|
||||||
|
|
||||||
|
deezerMaxSearchCacheEntries = 300
|
||||||
|
deezerMaxAlbumCacheEntries = 200
|
||||||
|
deezerMaxArtistCacheEntries = 200
|
||||||
|
deezerMaxISRCCacheEntries = 4000
|
||||||
|
deezerCacheCleanupInterval = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type DeezerClient struct {
|
type DeezerClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
searchCache map[string]*cacheEntry
|
searchCache map[string]*cacheEntry
|
||||||
albumCache map[string]*cacheEntry
|
albumCache map[string]*cacheEntry
|
||||||
artistCache map[string]*cacheEntry
|
artistCache map[string]*cacheEntry
|
||||||
isrcCache map[string]string
|
isrcCache map[string]string
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
|
lastCacheCleanup time.Time
|
||||||
|
cacheCleanupInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -47,16 +55,111 @@ var (
|
|||||||
func GetDeezerClient() *DeezerClient {
|
func GetDeezerClient() *DeezerClient {
|
||||||
deezerClientOnce.Do(func() {
|
deezerClientOnce.Do(func() {
|
||||||
deezerClient = &DeezerClient{
|
deezerClient = &DeezerClient{
|
||||||
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
|
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
|
||||||
searchCache: make(map[string]*cacheEntry),
|
searchCache: make(map[string]*cacheEntry),
|
||||||
albumCache: make(map[string]*cacheEntry),
|
albumCache: make(map[string]*cacheEntry),
|
||||||
artistCache: make(map[string]*cacheEntry),
|
artistCache: make(map[string]*cacheEntry),
|
||||||
isrcCache: make(map[string]string),
|
isrcCache: make(map[string]string),
|
||||||
|
cacheCleanupInterval: deezerCacheCleanupInterval,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return deezerClient
|
return deezerClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
|
||||||
|
cache map[string]*cacheEntry,
|
||||||
|
now time.Time,
|
||||||
|
) {
|
||||||
|
for key, entry := range cache {
|
||||||
|
if entry == nil || now.After(entry.expiresAt) {
|
||||||
|
delete(cache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) trimCacheEntriesLocked(
|
||||||
|
cache map[string]*cacheEntry,
|
||||||
|
maxEntries int,
|
||||||
|
) {
|
||||||
|
if maxEntries <= 0 || len(cache) <= maxEntries {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(cache) > maxEntries {
|
||||||
|
var oldestKey string
|
||||||
|
var oldestExpiry time.Time
|
||||||
|
first := true
|
||||||
|
for key, entry := range cache {
|
||||||
|
expiry := time.Time{}
|
||||||
|
if entry != nil {
|
||||||
|
expiry = entry.expiresAt
|
||||||
|
}
|
||||||
|
if first || expiry.Before(oldestExpiry) {
|
||||||
|
first = false
|
||||||
|
oldestKey = key
|
||||||
|
oldestExpiry = expiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldestKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(cache, oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) trimStringCacheEntriesLocked(
|
||||||
|
cache map[string]string,
|
||||||
|
maxEntries int,
|
||||||
|
) {
|
||||||
|
if maxEntries <= 0 || len(cache) <= maxEntries {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toRemove := len(cache) - maxEntries
|
||||||
|
for key := range cache {
|
||||||
|
delete(cache, key)
|
||||||
|
toRemove--
|
||||||
|
if toRemove <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
|
||||||
|
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
|
||||||
|
(c.lastCacheCleanup.IsZero() ||
|
||||||
|
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
|
||||||
|
|
||||||
|
if periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
||||||
|
c.lastCacheCleanup = now
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.searchCache) > deezerMaxSearchCacheEntries {
|
||||||
|
if !periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
||||||
|
}
|
||||||
|
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
|
||||||
|
}
|
||||||
|
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
|
||||||
|
if !periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
||||||
|
}
|
||||||
|
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
|
||||||
|
}
|
||||||
|
if len(c.artistCache) > deezerMaxArtistCacheEntries {
|
||||||
|
if !periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
||||||
|
}
|
||||||
|
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
|
||||||
|
}
|
||||||
|
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
|
||||||
|
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type deezerTrack struct {
|
type deezerTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -414,10 +517,12 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
|
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -555,10 +660,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.albumCache[albumID] = &cacheEntry{
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -638,10 +745,12 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.artistCache[artistID] = &cacheEntry{
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -807,6 +916,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
for trackIDStr, isrc := range directISRCs {
|
for trackIDStr, isrc := range directISRCs {
|
||||||
c.isrcCache[trackIDStr] = isrc
|
c.isrcCache[trackIDStr] = isrc
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(time.Now())
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,6 +951,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
||||||
|
c.maybeCleanupCachesLocked(time.Now())
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
}(track)
|
}(track)
|
||||||
}
|
}
|
||||||
@@ -864,6 +975,7 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
|||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.isrcCache[trackID] = fullTrack.ISRC
|
c.isrcCache[trackID] = fullTrack.ISRC
|
||||||
|
c.maybeCleanupCachesLocked(time.Now())
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return fullTrack.ISRC, nil
|
return fullTrack.ISRC, nil
|
||||||
@@ -946,10 +1058,12 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -47,10 +48,30 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
|||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if shouldTrySpotFetchFallback(err) {
|
||||||
|
data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
||||||
|
if apiErr == nil {
|
||||||
|
jsonBytes, marshalErr := json.Marshal(data)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return "", marshalErr
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if shouldTrySpotFetchFallback(err) {
|
||||||
|
fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
||||||
|
if apiErr == nil {
|
||||||
|
jsonBytes, marshalErr := json.Marshal(fallbackData)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return "", marshalErr
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +171,8 @@ type DownloadRequest struct {
|
|||||||
QobuzID string `json:"qobuz_id,omitempty"`
|
QobuzID string `json:"qobuz_id,omitempty"`
|
||||||
DeezerID string `json:"deezer_id,omitempty"`
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||||
|
UseExtensions bool `json:"use_extensions,omitempty"`
|
||||||
|
UseFallback bool `json:"use_fallback,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResponse struct {
|
type DownloadResponse struct {
|
||||||
@@ -176,20 +199,179 @@ type DownloadResponse struct {
|
|||||||
Copyright string `json:"copyright,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||||
|
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResult struct {
|
type DownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
Album string
|
Album string
|
||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
LyricsLRC string
|
Genre string
|
||||||
|
Label string
|
||||||
|
Copyright string
|
||||||
|
LyricsLRC string
|
||||||
|
DecryptionKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDownloadSuccessResponse(
|
||||||
|
req DownloadRequest,
|
||||||
|
result DownloadResult,
|
||||||
|
service string,
|
||||||
|
message string,
|
||||||
|
filePath string,
|
||||||
|
alreadyExists bool,
|
||||||
|
) DownloadResponse {
|
||||||
|
title := result.Title
|
||||||
|
if title == "" {
|
||||||
|
title = req.TrackName
|
||||||
|
}
|
||||||
|
|
||||||
|
artist := result.Artist
|
||||||
|
if artist == "" {
|
||||||
|
artist = req.ArtistName
|
||||||
|
}
|
||||||
|
|
||||||
|
album := result.Album
|
||||||
|
if album == "" {
|
||||||
|
album = req.AlbumName
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDate := result.ReleaseDate
|
||||||
|
if releaseDate == "" {
|
||||||
|
releaseDate = req.ReleaseDate
|
||||||
|
}
|
||||||
|
|
||||||
|
trackNumber := result.TrackNumber
|
||||||
|
if trackNumber == 0 {
|
||||||
|
trackNumber = req.TrackNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
discNumber := result.DiscNumber
|
||||||
|
if discNumber == 0 {
|
||||||
|
discNumber = req.DiscNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
isrc := result.ISRC
|
||||||
|
if isrc == "" {
|
||||||
|
isrc = req.ISRC
|
||||||
|
}
|
||||||
|
|
||||||
|
genre := result.Genre
|
||||||
|
if genre == "" {
|
||||||
|
genre = req.Genre
|
||||||
|
}
|
||||||
|
|
||||||
|
label := result.Label
|
||||||
|
if label == "" {
|
||||||
|
label = req.Label
|
||||||
|
}
|
||||||
|
|
||||||
|
copyright := result.Copyright
|
||||||
|
if copyright == "" {
|
||||||
|
copyright = req.Copyright
|
||||||
|
}
|
||||||
|
|
||||||
|
return DownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: message,
|
||||||
|
FilePath: filePath,
|
||||||
|
AlreadyExists: alreadyExists,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: service,
|
||||||
|
Title: title,
|
||||||
|
Artist: artist,
|
||||||
|
Album: album,
|
||||||
|
AlbumArtist: req.AlbumArtist,
|
||||||
|
ReleaseDate: releaseDate,
|
||||||
|
TrackNumber: trackNumber,
|
||||||
|
DiscNumber: discNumber,
|
||||||
|
ISRC: isrc,
|
||||||
|
CoverURL: req.CoverURL,
|
||||||
|
Genre: genre,
|
||||||
|
Label: label,
|
||||||
|
Copyright: copyright,
|
||||||
|
LyricsLRC: result.LyricsLRC,
|
||||||
|
DecryptionKey: result.DecryptionKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkipQualityProbe(filePath string) bool {
|
||||||
|
path := strings.TrimSpace(filePath)
|
||||||
|
if path == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Content URI and other non-filesystem schemes cannot be read directly by os.Open.
|
||||||
|
if strings.Contains(path, "://") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichResultQualityFromFile(result *DownloadResult) {
|
||||||
|
if result == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimSpace(result.FilePath)
|
||||||
|
if shouldSkipQualityProbe(path) {
|
||||||
|
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||||
|
LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quality, qErr := GetAudioQuality(path)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||||
|
if req == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
deezerClient := GetDeezerClient()
|
||||||
|
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||||
|
if err != nil || extMeta == nil {
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[DownloadWithFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Genre == "" && extMeta.Genre != "" {
|
||||||
|
req.Genre = extMeta.Genre
|
||||||
|
}
|
||||||
|
if req.Label == "" && extMeta.Label != "" {
|
||||||
|
req.Label = extMeta.Label
|
||||||
|
}
|
||||||
|
if req.Genre != "" || req.Label != "" {
|
||||||
|
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func DownloadTrack(requestJSON string) (string, error) {
|
func DownloadTrack(requestJSON string) (string, error) {
|
||||||
@@ -210,6 +392,8 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
AddAllowedDownloadDir(req.OutputDir)
|
AddAllowedDownloadDir(req.OutputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enrichRequestExtendedMetadata(&req)
|
||||||
|
|
||||||
var result DownloadResult
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -254,17 +438,18 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
Title: amazonResult.Title,
|
Title: amazonResult.Title,
|
||||||
Artist: amazonResult.Artist,
|
Artist: amazonResult.Artist,
|
||||||
Album: amazonResult.Album,
|
Album: amazonResult.Album,
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
LyricsLRC: amazonResult.LyricsLRC,
|
LyricsLRC: amazonResult.LyricsLRC,
|
||||||
|
DecryptionKey: amazonResult.DecryptionKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
@@ -296,61 +481,76 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
actualPath := result.FilePath[7:]
|
actualPath := result.FilePath[7:]
|
||||||
quality, qErr := GetAudioQuality(actualPath)
|
result.FilePath = actualPath
|
||||||
if qErr == nil {
|
enrichResultQualityFromFile(&result)
|
||||||
result.BitDepth = quality.BitDepth
|
resp := buildDownloadSuccessResponse(
|
||||||
result.SampleRate = quality.SampleRate
|
req,
|
||||||
}
|
result,
|
||||||
resp := DownloadResponse{
|
req.Service,
|
||||||
Success: true,
|
"File already exists",
|
||||||
Message: "File already exists",
|
actualPath,
|
||||||
FilePath: actualPath,
|
true,
|
||||||
AlreadyExists: true,
|
)
|
||||||
ActualBitDepth: result.BitDepth,
|
|
||||||
ActualSampleRate: result.SampleRate,
|
|
||||||
Service: req.Service,
|
|
||||||
Title: result.Title,
|
|
||||||
Artist: result.Artist,
|
|
||||||
Album: result.Album,
|
|
||||||
ReleaseDate: result.ReleaseDate,
|
|
||||||
TrackNumber: result.TrackNumber,
|
|
||||||
DiscNumber: result.DiscNumber,
|
|
||||||
ISRC: result.ISRC,
|
|
||||||
}
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
enrichResultQualityFromFile(&result)
|
||||||
if qErr == nil {
|
|
||||||
result.BitDepth = quality.BitDepth
|
|
||||||
result.SampleRate = quality.SampleRate
|
|
||||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
|
||||||
} else {
|
|
||||||
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := buildDownloadSuccessResponse(
|
||||||
Success: true,
|
req,
|
||||||
Message: "Download complete",
|
result,
|
||||||
FilePath: result.FilePath,
|
req.Service,
|
||||||
ActualBitDepth: result.BitDepth,
|
"Download complete",
|
||||||
ActualSampleRate: result.SampleRate,
|
result.FilePath,
|
||||||
Service: req.Service,
|
false,
|
||||||
Title: result.Title,
|
)
|
||||||
Artist: result.Artist,
|
|
||||||
Album: result.Album,
|
|
||||||
ReleaseDate: result.ReleaseDate,
|
|
||||||
TrackNumber: result.TrackNumber,
|
|
||||||
DiscNumber: result.DiscNumber,
|
|
||||||
ISRC: result.ISRC,
|
|
||||||
LyricsLRC: result.LyricsLRC,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadByStrategy routes a unified download request to the appropriate flow.
|
||||||
|
// Routing priority: YouTube service > extension fallback > built-in fallback > direct service.
|
||||||
|
func DownloadByStrategy(requestJSON string) (string, error) {
|
||||||
|
var req DownloadRequest
|
||||||
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
|
return errorResponse("Invalid request: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceRaw := strings.TrimSpace(req.Service)
|
||||||
|
serviceNormalized := strings.ToLower(serviceRaw)
|
||||||
|
|
||||||
|
normalizedReq := req
|
||||||
|
if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) {
|
||||||
|
normalizedReq.Service = serviceNormalized
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedBytes, err := json.Marshal(normalizedReq)
|
||||||
|
if err != nil {
|
||||||
|
return errorResponse("Invalid request: " + err.Error())
|
||||||
|
}
|
||||||
|
normalizedJSON := string(normalizedBytes)
|
||||||
|
|
||||||
|
if serviceNormalized == "youtube" {
|
||||||
|
return DownloadFromYouTube(normalizedJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.UseExtensions {
|
||||||
|
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
|
||||||
|
if err != nil {
|
||||||
|
return errorResponse(err.Error())
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.UseFallback {
|
||||||
|
return DownloadWithFallback(normalizedJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DownloadTrack(normalizedJSON)
|
||||||
|
}
|
||||||
|
|
||||||
func DownloadWithFallback(requestJSON string) (string, error) {
|
func DownloadWithFallback(requestJSON string) (string, error) {
|
||||||
var req DownloadRequest
|
var req DownloadRequest
|
||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
@@ -369,6 +569,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
AddAllowedDownloadDir(req.OutputDir)
|
AddAllowedDownloadDir(req.OutputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enrichRequestExtendedMetadata(&req)
|
||||||
|
|
||||||
allServices := []string{"tidal", "qobuz", "amazon"}
|
allServices := []string{"tidal", "qobuz", "amazon"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
@@ -440,17 +642,18 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
Title: amazonResult.Title,
|
Title: amazonResult.Title,
|
||||||
Artist: amazonResult.Artist,
|
Artist: amazonResult.Artist,
|
||||||
Album: amazonResult.Album,
|
Album: amazonResult.Album,
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
LyricsLRC: amazonResult.LyricsLRC,
|
LyricsLRC: amazonResult.LyricsLRC,
|
||||||
|
DecryptionKey: amazonResult.DecryptionKey,
|
||||||
}
|
}
|
||||||
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
||||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||||
@@ -465,57 +668,30 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
actualPath := result.FilePath[7:]
|
actualPath := result.FilePath[7:]
|
||||||
quality, qErr := GetAudioQuality(actualPath)
|
result.FilePath = actualPath
|
||||||
if qErr == nil {
|
enrichResultQualityFromFile(&result)
|
||||||
result.BitDepth = quality.BitDepth
|
resp := buildDownloadSuccessResponse(
|
||||||
result.SampleRate = quality.SampleRate
|
req,
|
||||||
}
|
result,
|
||||||
resp := DownloadResponse{
|
service,
|
||||||
Success: true,
|
"File already exists",
|
||||||
Message: "File already exists",
|
actualPath,
|
||||||
FilePath: actualPath,
|
true,
|
||||||
AlreadyExists: true,
|
)
|
||||||
ActualBitDepth: result.BitDepth,
|
|
||||||
ActualSampleRate: result.SampleRate,
|
|
||||||
Service: service,
|
|
||||||
Title: result.Title,
|
|
||||||
Artist: result.Artist,
|
|
||||||
Album: result.Album,
|
|
||||||
ReleaseDate: result.ReleaseDate,
|
|
||||||
TrackNumber: result.TrackNumber,
|
|
||||||
DiscNumber: result.DiscNumber,
|
|
||||||
ISRC: result.ISRC,
|
|
||||||
LyricsLRC: result.LyricsLRC,
|
|
||||||
}
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
enrichResultQualityFromFile(&result)
|
||||||
if qErr == nil {
|
|
||||||
result.BitDepth = quality.BitDepth
|
|
||||||
result.SampleRate = quality.SampleRate
|
|
||||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
|
||||||
} else {
|
|
||||||
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := buildDownloadSuccessResponse(
|
||||||
Success: true,
|
req,
|
||||||
Message: "Downloaded from " + service,
|
result,
|
||||||
FilePath: result.FilePath,
|
service,
|
||||||
ActualBitDepth: result.BitDepth,
|
"Downloaded from "+service,
|
||||||
ActualSampleRate: result.SampleRate,
|
result.FilePath,
|
||||||
Service: service,
|
false,
|
||||||
Title: result.Title,
|
)
|
||||||
Artist: result.Artist,
|
|
||||||
Album: result.Album,
|
|
||||||
ReleaseDate: result.ReleaseDate,
|
|
||||||
TrackNumber: result.TrackNumber,
|
|
||||||
DiscNumber: result.DiscNumber,
|
|
||||||
ISRC: result.ISRC,
|
|
||||||
LyricsLRC: result.LyricsLRC,
|
|
||||||
}
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
@@ -622,6 +798,7 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["track_number"] = meta.TrackNumber
|
result["track_number"] = meta.TrackNumber
|
||||||
result["disc_number"] = meta.DiscNumber
|
result["disc_number"] = meta.DiscNumber
|
||||||
result["isrc"] = meta.ISRC
|
result["isrc"] = meta.ISRC
|
||||||
|
result["lyrics"] = meta.Lyrics
|
||||||
result["genre"] = meta.Genre
|
result["genre"] = meta.Genre
|
||||||
result["composer"] = meta.Composer
|
result["composer"] = meta.Composer
|
||||||
result["comment"] = meta.Comment
|
result["comment"] = meta.Comment
|
||||||
@@ -646,6 +823,7 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["track_number"] = meta.TrackNumber
|
result["track_number"] = meta.TrackNumber
|
||||||
result["disc_number"] = meta.DiscNumber
|
result["disc_number"] = meta.DiscNumber
|
||||||
result["isrc"] = meta.ISRC
|
result["isrc"] = meta.ISRC
|
||||||
|
result["lyrics"] = meta.Lyrics
|
||||||
result["genre"] = meta.Genre
|
result["genre"] = meta.Genre
|
||||||
result["composer"] = meta.Composer
|
result["composer"] = meta.Composer
|
||||||
result["comment"] = meta.Comment
|
result["comment"] = meta.Comment
|
||||||
@@ -678,6 +856,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|||||||
|
|
||||||
lower := strings.ToLower(filePath)
|
lower := strings.ToLower(filePath)
|
||||||
isFlac := strings.HasSuffix(lower, ".flac")
|
isFlac := strings.HasSuffix(lower, ".flac")
|
||||||
|
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||||
|
|
||||||
if isFlac {
|
if isFlac {
|
||||||
trackNum := 0
|
trackNum := 0
|
||||||
@@ -705,7 +884,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|||||||
Comment: fields["comment"],
|
Comment: fields["comment"],
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadata(filePath, meta, ""); err != nil {
|
if err := EmbedMetadata(filePath, meta, coverPath); err != nil {
|
||||||
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
|
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,9 +1280,12 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
var spotifyErr error
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
||||||
|
spotifyErr = err
|
||||||
} else {
|
} else {
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1114,28 +1296,81 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
errStr := strings.ToLower(err.Error())
|
spotifyErr = err
|
||||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
if !shouldTrySpotFetchFallback(err) {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
||||||
|
if apiErr == nil {
|
||||||
|
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
|
||||||
|
jsonBytes, err := json.Marshal(spotFetchData)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
GoLog("[Fallback] SpotFetch API fallback failed: %v\n", apiErr)
|
||||||
|
|
||||||
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
if spotifyErr != nil {
|
||||||
|
return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
GoLog("[Fallback] Trying Deezer conversion fallback for %s...\n", parsed.Type)
|
||||||
|
|
||||||
if parsed.Type == "track" || parsed.Type == "album" {
|
if parsed.Type == "track" || parsed.Type == "album" {
|
||||||
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Type == "artist" {
|
if parsed.Type == "artist" {
|
||||||
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
if spotifyErr != nil {
|
||||||
|
return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
if spotifyErr != nil {
|
||||||
|
return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldTrySpotFetchFallback(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrNoSpotifyCredentials) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
indicators := []string{
|
||||||
|
"429",
|
||||||
|
"rate",
|
||||||
|
"limit",
|
||||||
|
"403",
|
||||||
|
"forbidden",
|
||||||
|
"401",
|
||||||
|
"unauthorized",
|
||||||
|
"timeout",
|
||||||
|
"connection",
|
||||||
|
"spotify error",
|
||||||
|
"access token",
|
||||||
|
"client token",
|
||||||
|
"eof",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, indicator := range indicators {
|
||||||
|
if strings.Contains(errStr, indicator) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||||
@@ -1266,6 +1501,10 @@ func DownloadFromYouTube(requestJSON string) (string, error) {
|
|||||||
DiscNumber: youtubeResult.DiscNumber,
|
DiscNumber: youtubeResult.DiscNumber,
|
||||||
ISRC: youtubeResult.ISRC,
|
ISRC: youtubeResult.ISRC,
|
||||||
LyricsLRC: youtubeResult.LyricsLRC,
|
LyricsLRC: youtubeResult.LyricsLRC,
|
||||||
|
CoverURL: req.CoverURL,
|
||||||
|
Genre: req.Genre,
|
||||||
|
Label: req.Label,
|
||||||
|
Copyright: req.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
@@ -1528,19 +1767,47 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
|
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
|
||||||
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
|
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
|
||||||
|
|
||||||
|
lower := strings.ToLower(req.FilePath)
|
||||||
|
isFlac := strings.HasSuffix(lower, ".flac")
|
||||||
|
|
||||||
// Download cover art to temp file
|
// Download cover art to temp file
|
||||||
var coverTempPath string
|
var coverTempPath string
|
||||||
|
var coverDataBytes []byte
|
||||||
if req.CoverURL != "" {
|
if req.CoverURL != "" {
|
||||||
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
|
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
|
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
|
coverDataBytes = coverData
|
||||||
if err == nil {
|
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
|
||||||
coverTempPath = tmpFile.Name()
|
// MP3/Opus requires a real image file path for Dart FFmpeg.
|
||||||
tmpFile.Write(coverData)
|
// FLAC uses in-memory embed and does not require temp files.
|
||||||
tmpFile.Close()
|
if !isFlac {
|
||||||
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
|
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
fallbackDir := filepath.Dir(req.FilePath)
|
||||||
|
if fallbackDir == "" || fallbackDir == "." {
|
||||||
|
GoLog("[ReEnrich] Failed to create cover temp file: %v\n", err)
|
||||||
|
} else {
|
||||||
|
tmpFile, err = os.CreateTemp(fallbackDir, "reenrich_cover_*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[ReEnrich] Failed to create cover temp file (fallback dir %s): %v\n", fallbackDir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil && tmpFile != nil {
|
||||||
|
coverTempPath = tmpFile.Name()
|
||||||
|
if _, writeErr := tmpFile.Write(coverData); writeErr != nil {
|
||||||
|
GoLog("[ReEnrich] Failed writing cover temp file: %v\n", writeErr)
|
||||||
|
tmpFile.Close()
|
||||||
|
os.Remove(coverTempPath)
|
||||||
|
coverTempPath = ""
|
||||||
|
} else if closeErr := tmpFile.Close(); closeErr != nil {
|
||||||
|
GoLog("[ReEnrich] Failed closing cover temp file: %v\n", closeErr)
|
||||||
|
os.Remove(coverTempPath)
|
||||||
|
coverTempPath = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1570,9 +1837,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lower := strings.ToLower(req.FilePath)
|
|
||||||
isFlac := strings.HasSuffix(lower, ".flac")
|
|
||||||
|
|
||||||
// Build enriched metadata response for Dart (includes online search results)
|
// Build enriched metadata response for Dart (includes online search results)
|
||||||
enrichedMeta := map[string]interface{}{
|
enrichedMeta := map[string]interface{}{
|
||||||
"track_name": req.TrackName,
|
"track_name": req.TrackName,
|
||||||
@@ -1608,8 +1872,24 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
Lyrics: lyricsLRC,
|
Lyrics: lyricsLRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadata(req.FilePath, metadata, coverTempPath); err != nil {
|
if len(coverDataBytes) > 0 {
|
||||||
return "", fmt.Errorf("failed to embed metadata: %w", err)
|
if err := EmbedMetadataWithCoverData(req.FilePath, metadata, coverDataBytes); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to embed metadata with cover: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := EmbedMetadata(req.FilePath, metadata, ""); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to embed metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(coverDataBytes) > 0 {
|
||||||
|
embeddedCover, err := ExtractCoverArt(req.FilePath)
|
||||||
|
if err != nil || len(embeddedCover) == 0 {
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("metadata embedded but cover verification failed: %w", err)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("metadata embedded but cover verification failed: empty embedded cover")
|
||||||
|
}
|
||||||
|
GoLog("[ReEnrich] Cover verified after embed (%d bytes)\n", len(embeddedCover))
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
|
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
|
||||||
@@ -2699,14 +2979,26 @@ func GetStoreCategoriesJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
|
||||||
|
if strings.TrimSpace(extensionID) == "" {
|
||||||
|
return "", fmt.Errorf("invalid extension id")
|
||||||
|
}
|
||||||
|
|
||||||
|
safeExtensionID := sanitizeFilename(extensionID)
|
||||||
|
return filepath.Join(destDir, safeExtensionID+".spotiflac-ext"), nil
|
||||||
|
}
|
||||||
|
|
||||||
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||||
store := GetExtensionStore()
|
store := GetExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return "", fmt.Errorf("extension store not initialized")
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
destPath := fmt.Sprintf("%s/%s.spotiflac-ext", destDir, extensionID)
|
destPath, err := buildStoreExtensionDestPath(destDir, extensionID)
|
||||||
err := store.DownloadExtension(extensionID, destPath)
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
err = store.DownloadExtension(extensionID, destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1082,16 +1082,18 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
Title: amazonResult.Title,
|
Title: amazonResult.Title,
|
||||||
Artist: amazonResult.Artist,
|
Artist: amazonResult.Artist,
|
||||||
Album: amazonResult.Album,
|
Album: amazonResult.Album,
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
|
LyricsLRC: amazonResult.LyricsLRC,
|
||||||
|
DecryptionKey: amazonResult.DecryptionKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
@@ -1119,6 +1121,8 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
|
LyricsLRC: result.LyricsLRC,
|
||||||
|
DecryptionKey: result.DecryptionKey,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1132,8 +1136,13 @@ func buildOutputPath(req DownloadRequest) string {
|
|||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"album_artist": req.AlbumArtist,
|
"album_artist": req.AlbumArtist,
|
||||||
|
"track": req.TrackNumber,
|
||||||
"track_number": req.TrackNumber,
|
"track_number": req.TrackNumber,
|
||||||
|
"disc": req.DiscNumber,
|
||||||
"disc_number": req.DiscNumber,
|
"disc_number": req.DiscNumber,
|
||||||
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
|
"release_date": req.ReleaseDate,
|
||||||
"isrc": req.ISRC,
|
"isrc": req.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,16 +1173,30 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
optionsJSON, _ := json.Marshal(options)
|
if options == nil {
|
||||||
|
options = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
// Avoid embedding user input directly into JS source. Some inputs can trigger
|
||||||
|
// parser/runtime edge cases on specific devices/Goja builds.
|
||||||
|
const queryVar = "__sf_custom_search_query"
|
||||||
|
const optionsVar = "__sf_custom_search_options"
|
||||||
|
global := p.vm.GlobalObject()
|
||||||
|
_ = global.Set(queryVar, query)
|
||||||
|
_ = global.Set(optionsVar, options)
|
||||||
|
defer func() {
|
||||||
|
global.Delete(queryVar)
|
||||||
|
global.Delete(optionsVar)
|
||||||
|
}()
|
||||||
|
|
||||||
|
const script = `
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
|
||||||
return extension.customSearch(%q, %s);
|
return extension.customSearch(__sf_custom_search_query, __sf_custom_search_options);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})()
|
})()
|
||||||
`, query, string(optionsJSON))
|
`
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1358,12 +1381,12 @@ type PostProcessResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PostProcessInput struct {
|
type PostProcessInput struct {
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
URI string `json:"uri,omitempty"`
|
URI string `json:"uri,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
MimeType string `json:"mime_type,omitempty"`
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
Size int64 `json:"size,omitempty"`
|
Size int64 `json:"size,omitempty"`
|
||||||
IsSAF bool `json:"is_saf,omitempty"`
|
IsSAF bool `json:"is_saf,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostProcessTimeout = 2 * time.Minute
|
const PostProcessTimeout = 2 * time.Minute
|
||||||
|
|||||||
@@ -18,6 +18,43 @@ import (
|
|||||||
|
|
||||||
// ==================== Auth API (OAuth Support) ====================
|
// ==================== Auth API (OAuth Support) ====================
|
||||||
|
|
||||||
|
func validateExtensionAuthURL(urlStr string) error {
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid auth URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("invalid auth URL: only https is allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
host := parsed.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return fmt.Errorf("invalid auth URL: hostname is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.User != nil {
|
||||||
|
return fmt.Errorf("invalid auth URL: embedded credentials are not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPrivateIP(host) {
|
||||||
|
return fmt.Errorf("invalid auth URL: private/local network is not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeURLForLog(urlStr string) string {
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return urlStr
|
||||||
|
}
|
||||||
|
if parsed.Host == "" {
|
||||||
|
return parsed.Scheme + "://"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -32,6 +69,13 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
callbackURL = call.Arguments[1].String()
|
callbackURL = call.Arguments[1].String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pendingAuthRequestsMu.Lock()
|
pendingAuthRequestsMu.Lock()
|
||||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
ExtensionID: r.extensionID,
|
ExtensionID: r.extensionID,
|
||||||
@@ -50,7 +94,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
state.AuthCode = ""
|
state.AuthCode = ""
|
||||||
extensionAuthStateMu.Unlock()
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
|
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -273,6 +317,12 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
"error": "authUrl, clientId, and redirectUri are required",
|
"error": "authUrl, clientId, and redirectUri are required",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
scope, _ := config["scope"].(string)
|
scope, _ := config["scope"].(string)
|
||||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||||
@@ -331,7 +381,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
}
|
}
|
||||||
pendingAuthRequestsMu.Unlock()
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
|
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -441,13 +491,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
bodyPreview := sanitizeSensitiveLogText(string(body))
|
||||||
|
if len(bodyPreview) > 1000 {
|
||||||
|
bodyPreview = bodyPreview[:1000] + "...[truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
var tokenResp map[string]interface{}
|
var tokenResp map[string]interface{}
|
||||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||||
"body": string(body),
|
"body": bodyPreview,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +522,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "no access_token in response",
|
"error": "no access_token in response",
|
||||||
"body": string(body),
|
"body": bodyPreview,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
|||||||
if parsed.Scheme != "https" {
|
if parsed.Scheme != "https" {
|
||||||
return fmt.Errorf("network access denied: only https is allowed")
|
return fmt.Errorf("network access denied: only https is allowed")
|
||||||
}
|
}
|
||||||
|
if parsed.User != nil {
|
||||||
|
return fmt.Errorf("invalid URL: embedded credentials are not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
domain := parsed.Hostname()
|
domain := parsed.Hostname()
|
||||||
if domain == "" {
|
if domain == "" {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(storagePath, data, 0644)
|
return os.WriteFile(storagePath, data, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}}
|
}}
|
||||||
} else {
|
} else {
|
||||||
|
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
var (
|
||||||
|
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||||
|
multiUnderscore = regexp.MustCompile(`_+`)
|
||||||
|
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
|
||||||
|
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
||||||
|
yearPattern = regexp.MustCompile(`\d{4}`)
|
||||||
|
)
|
||||||
|
|
||||||
func sanitizeFilename(filename string) string {
|
func sanitizeFilename(filename string) string {
|
||||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||||
@@ -14,7 +22,6 @@ func sanitizeFilename(filename string) string {
|
|||||||
sanitized = strings.TrimSpace(sanitized)
|
sanitized = strings.TrimSpace(sanitized)
|
||||||
sanitized = strings.Trim(sanitized, ".")
|
sanitized = strings.Trim(sanitized, ".")
|
||||||
|
|
||||||
multiUnderscore := regexp.MustCompile(`_+`)
|
|
||||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||||
|
|
||||||
if len(sanitized) > 200 {
|
if len(sanitized) > 200 {
|
||||||
@@ -33,15 +40,25 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
template = "{artist} - {title}"
|
template = "{artist} - {title}"
|
||||||
}
|
}
|
||||||
|
|
||||||
result := template
|
result := replaceFormattedNumberPlaceholders(template, metadata)
|
||||||
|
result = replaceDateFormatPlaceholders(result, metadata)
|
||||||
|
|
||||||
|
dateValue := getDateValue(metadata)
|
||||||
|
yearValue := getString(metadata, "year")
|
||||||
|
if yearValue == "" {
|
||||||
|
yearValue = extractYear(dateValue)
|
||||||
|
}
|
||||||
|
|
||||||
placeholders := map[string]string{
|
placeholders := map[string]string{
|
||||||
"{title}": getString(metadata, "title"),
|
"{title}": getString(metadata, "title"),
|
||||||
"{artist}": getString(metadata, "artist"),
|
"{artist}": getString(metadata, "artist"),
|
||||||
"{album}": getString(metadata, "album"),
|
"{album}": getString(metadata, "album"),
|
||||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||||
"{year}": getString(metadata, "year"),
|
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
||||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
"{year}": yearValue,
|
||||||
|
"{date}": dateValue,
|
||||||
|
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||||
|
"{disc_raw}": formatRawNumber(getInt(metadata, "disc")),
|
||||||
}
|
}
|
||||||
|
|
||||||
for placeholder, value := range placeholders {
|
for placeholder, value := range placeholders {
|
||||||
@@ -51,26 +68,91 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func replaceFormattedNumberPlaceholders(template string, metadata map[string]interface{}) string {
|
||||||
|
return formattedNumberPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
|
||||||
|
parts := formattedNumberPlaceholderExpr.FindStringSubmatch(match)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
number := getInt(metadata, parts[1])
|
||||||
|
width, err := strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatNumberWithWidth(number, width)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceDateFormatPlaceholders(template string, metadata map[string]interface{}) string {
|
||||||
|
return dateFormatPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
|
||||||
|
parts := dateFormatPlaceholderExpr.FindStringSubmatch(match)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDateWithPattern(getDateValue(metadata), parts[1])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDateValue(metadata map[string]interface{}) string {
|
||||||
|
date := getString(metadata, "date")
|
||||||
|
if date != "" {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDate := getString(metadata, "release_date")
|
||||||
|
if releaseDate != "" {
|
||||||
|
return releaseDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return getString(metadata, "year")
|
||||||
|
}
|
||||||
|
|
||||||
func getString(m map[string]interface{}, key string) string {
|
func getString(m map[string]interface{}, key string) string {
|
||||||
if v, ok := m[key]; ok {
|
if v, ok := m[key]; ok {
|
||||||
if s, ok := v.(string); ok {
|
switch value := v.(type) {
|
||||||
return strings.TrimSpace(s)
|
case string:
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(value)
|
||||||
|
case int64:
|
||||||
|
return strconv.FormatInt(value, 10)
|
||||||
|
case float64:
|
||||||
|
return strconv.Itoa(int(value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func getInt(m map[string]interface{}, key string) int {
|
func getInt(m map[string]interface{}, key string) int {
|
||||||
if v, ok := m[key]; ok {
|
candidateKeys := []string{key}
|
||||||
switch n := v.(type) {
|
switch key {
|
||||||
case int:
|
case "track":
|
||||||
return n
|
candidateKeys = append(candidateKeys, "track_number")
|
||||||
case int64:
|
case "disc":
|
||||||
return int(n)
|
candidateKeys = append(candidateKeys, "disc_number")
|
||||||
case float64:
|
}
|
||||||
return int(n)
|
|
||||||
|
for _, candidate := range candidateKeys {
|
||||||
|
if v, ok := m[candidate]; ok {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int:
|
||||||
|
return n
|
||||||
|
case int64:
|
||||||
|
return int(n)
|
||||||
|
case float64:
|
||||||
|
return int(n)
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(n))
|
||||||
|
if err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +170,129 @@ func formatDiscNumber(n int) string {
|
|||||||
return fmt.Sprintf("%d", n)
|
return fmt.Sprintf("%d", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatRawNumber(n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatNumberWithWidth(n int, width int) string {
|
||||||
|
if n <= 0 || width <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if width <= 1 {
|
||||||
|
return formatRawNumber(n)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%0*d", width, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDateWithPattern(rawDate string, strftimePattern string) string {
|
||||||
|
if rawDate == "" || strftimePattern == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedDate, ok := parseMetadataDate(rawDate)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
goLayout := convertStrftimeToGoLayout(strftimePattern)
|
||||||
|
if goLayout == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedDate.Format(goLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMetadataDate(rawDate string) (time.Time, bool) {
|
||||||
|
clean := strings.TrimSpace(rawDate)
|
||||||
|
if clean == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
layouts := []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02",
|
||||||
|
"2006-01",
|
||||||
|
"2006",
|
||||||
|
"2006/01/02",
|
||||||
|
"2006/01",
|
||||||
|
"2006.01.02",
|
||||||
|
"2006.01",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, layout := range layouts {
|
||||||
|
parsed, err := time.Parse(layout, clean)
|
||||||
|
if err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(clean) >= 10 {
|
||||||
|
parsed, err := time.Parse("2006-01-02", clean[:10])
|
||||||
|
if err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yearMatch := yearPattern.FindString(clean)
|
||||||
|
if yearMatch == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
year, err := strconv.Atoi(yearMatch)
|
||||||
|
if err != nil || year <= 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertStrftimeToGoLayout(pattern string) string {
|
||||||
|
if pattern == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
for i := 0; i < len(pattern); i++ {
|
||||||
|
ch := pattern[i]
|
||||||
|
if ch != '%' {
|
||||||
|
builder.WriteByte(ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if i+1 >= len(pattern) {
|
||||||
|
builder.WriteByte('%')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
i++
|
||||||
|
switch pattern[i] {
|
||||||
|
case 'Y':
|
||||||
|
builder.WriteString("2006")
|
||||||
|
case 'y':
|
||||||
|
builder.WriteString("06")
|
||||||
|
case 'm':
|
||||||
|
builder.WriteString("01")
|
||||||
|
case 'd':
|
||||||
|
builder.WriteString("02")
|
||||||
|
case 'b':
|
||||||
|
builder.WriteString("Jan")
|
||||||
|
case 'B':
|
||||||
|
builder.WriteString("January")
|
||||||
|
case '%':
|
||||||
|
builder.WriteByte('%')
|
||||||
|
default:
|
||||||
|
builder.WriteByte('%')
|
||||||
|
builder.WriteByte(pattern[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
func extractYear(date string) string {
|
func extractYear(date string) string {
|
||||||
if len(date) >= 4 {
|
if len(date) >= 4 {
|
||||||
return date[:4]
|
return date[:4]
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"title": "Song Name",
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"album": "Album Name",
|
||||||
|
"track": 1,
|
||||||
|
"disc": 2,
|
||||||
|
"year": "2025",
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate(
|
||||||
|
"{artist} - {track} - {track_raw} - d{disc} - d{disc_raw} - {title}",
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
expected := "Artist Name - 01 - 1 - d2 - d2 - Song Name"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_RawPlaceholdersEmptyWhenZero(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"title": "Song Name",
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"track": 0,
|
||||||
|
"disc": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate("{track_raw}-{disc_raw}-{title}", metadata)
|
||||||
|
expected := "--Song Name"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"track": 3,
|
||||||
|
"disc": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate("{track:1}-{track:02}-{disc:03}", metadata)
|
||||||
|
expected := "3-03-002"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"title": "Song Name",
|
||||||
|
"release_date": "2024-03-09",
|
||||||
|
"track_number": 7,
|
||||||
|
"disc_number": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate(
|
||||||
|
"{artist} - {track:02} - {title} - {date:%Y-%m-%d} - {year}",
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
expected := "Artist Name - 07 - Song Name - 2024-03-09 - 2024"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"title": "Song Name",
|
||||||
|
"date": "2019",
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate("{date:%Y}-{date:%m}-{date:%d}", metadata)
|
||||||
|
expected := "2019-01-01"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ module github.com/zarz/spotiflac_android/go_backend
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
toolchain go1.25.7
|
toolchain go1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||||
@@ -10,8 +10,8 @@ require (
|
|||||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||||
github.com/go-flac/go-flac/v2 v2.0.4
|
github.com/go-flac/go-flac/v2 v2.0.4
|
||||||
github.com/refraction-networking/utls v1.8.2
|
github.com/refraction-networking/utls v1.8.2
|
||||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3
|
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
|
||||||
golang.org/x/net v0.49.0
|
golang.org/x/net v0.50.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -20,10 +20,10 @@ require (
|
|||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||||
github.com/klauspost/compress v1.17.4 // indirect
|
github.com/klauspost/compress v1.17.4 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/mod v0.32.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/tools v0.41.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,20 +30,34 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
||||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||||
|
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
||||||
|
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
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/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type LibraryScanResult struct {
|
|||||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||||
BitDepth int `json:"bitDepth,omitempty"`
|
BitDepth int `json:"bitDepth,omitempty"`
|
||||||
SampleRate int `json:"sampleRate,omitempty"`
|
SampleRate int `json:"sampleRate,omitempty"`
|
||||||
|
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||||
Genre string `json:"genre,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
Format string `json:"format,omitempty"`
|
Format string `json:"format,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -289,8 +290,11 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
quality, err := GetMP3Quality(filePath)
|
quality, err := GetMP3Quality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth // 0 for lossy
|
||||||
result.Duration = quality.Duration
|
result.Duration = quality.Duration
|
||||||
|
if quality.Bitrate > 0 {
|
||||||
|
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.TrackName == "" {
|
if result.TrackName == "" {
|
||||||
@@ -326,8 +330,11 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
quality, err := GetOggQuality(filePath)
|
quality, err := GetOggQuality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth // 0 for lossy
|
||||||
result.Duration = quality.Duration
|
result.Duration = quality.Duration
|
||||||
|
if quality.Bitrate > 0 {
|
||||||
|
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.TrackName == "" {
|
if result.TrackName == "" {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,8 +31,22 @@ const (
|
|||||||
var (
|
var (
|
||||||
globalLogBuffer *LogBuffer
|
globalLogBuffer *LogBuffer
|
||||||
logBufferOnce sync.Once
|
logBufferOnce sync.Once
|
||||||
|
|
||||||
|
authorizationBearerPattern = regexp.MustCompile(`(?i)\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||||
|
genericKeyValuePattern = regexp.MustCompile(`(?i)\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)`)
|
||||||
|
queryTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+`)
|
||||||
|
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func sanitizeSensitiveLogText(message string) string {
|
||||||
|
redacted := message
|
||||||
|
redacted = authorizationBearerPattern.ReplaceAllString(redacted, "Authorization: Bearer [REDACTED]")
|
||||||
|
redacted = genericKeyValuePattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`)
|
||||||
|
redacted = queryTokenPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
|
||||||
|
redacted = bearerTokenPattern.ReplaceAllString(redacted, "Bearer [REDACTED]")
|
||||||
|
return redacted
|
||||||
|
}
|
||||||
|
|
||||||
func GetLogBuffer() *LogBuffer {
|
func GetLogBuffer() *LogBuffer {
|
||||||
logBufferOnce.Do(func() {
|
logBufferOnce.Do(func() {
|
||||||
globalLogBuffer = &LogBuffer{
|
globalLogBuffer = &LogBuffer{
|
||||||
@@ -71,6 +86,7 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message = sanitizeSensitiveLogText(message)
|
||||||
message = truncateLogMessage(message)
|
message = truncateLogMessage(message)
|
||||||
|
|
||||||
entry := LogEntry{
|
entry := LogEntry{
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
stdimage "image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -14,6 +19,82 @@ import (
|
|||||||
"github.com/go-flac/go-flac/v2"
|
"github.com/go-flac/go-flac/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func detectCoverMIME(coverPath string, coverData []byte) string {
|
||||||
|
// Prefer magic-byte detection over file extension.
|
||||||
|
// Some providers return non-JPEG data behind .jpg URLs.
|
||||||
|
if len(coverData) >= 8 &&
|
||||||
|
coverData[0] == 0x89 &&
|
||||||
|
coverData[1] == 0x50 &&
|
||||||
|
coverData[2] == 0x4E &&
|
||||||
|
coverData[3] == 0x47 &&
|
||||||
|
coverData[4] == 0x0D &&
|
||||||
|
coverData[5] == 0x0A &&
|
||||||
|
coverData[6] == 0x1A &&
|
||||||
|
coverData[7] == 0x0A {
|
||||||
|
return "image/png"
|
||||||
|
}
|
||||||
|
if len(coverData) >= 3 &&
|
||||||
|
coverData[0] == 0xFF &&
|
||||||
|
coverData[1] == 0xD8 &&
|
||||||
|
coverData[2] == 0xFF {
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
if len(coverData) >= 6 {
|
||||||
|
header := string(coverData[:6])
|
||||||
|
if header == "GIF87a" || header == "GIF89a" {
|
||||||
|
return "image/gif"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(coverData) >= 12 &&
|
||||||
|
string(coverData[:4]) == "RIFF" &&
|
||||||
|
string(coverData[8:12]) == "WEBP" {
|
||||||
|
return "image/webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(filepath.Ext(strings.TrimSpace(coverPath))) {
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
|
||||||
|
if len(coverData) == 0 {
|
||||||
|
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
|
||||||
|
}
|
||||||
|
|
||||||
|
mime := detectCoverMIME(coverPath, coverData)
|
||||||
|
picture := &flacpicture.MetadataBlockPicture{
|
||||||
|
PictureType: flacpicture.PictureTypeFrontCover,
|
||||||
|
MIME: mime,
|
||||||
|
Description: "Front Cover",
|
||||||
|
ImageData: coverData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width/height/depth are optional in practice; keep zero when decode fails.
|
||||||
|
if cfg, format, err := stdimage.DecodeConfig(bytes.NewReader(coverData)); err == nil {
|
||||||
|
picture.Width = uint32(cfg.Width)
|
||||||
|
picture.Height = uint32(cfg.Height)
|
||||||
|
switch format {
|
||||||
|
case "png":
|
||||||
|
picture.ColorDepth = 32
|
||||||
|
case "jpeg":
|
||||||
|
picture.ColorDepth = 24
|
||||||
|
default:
|
||||||
|
picture.ColorDepth = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return picture.Marshal(), nil
|
||||||
|
}
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
@@ -127,19 +208,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picBlock, err := buildPictureBlock(coverPath, coverData)
|
||||||
flacpicture.PictureTypeFrontCover,
|
|
||||||
"Front Cover",
|
|
||||||
coverData,
|
|
||||||
"image/jpeg",
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
return fmt.Errorf("failed to create picture block: %w", err)
|
||||||
} else {
|
|
||||||
picBlock := picture.Marshal()
|
|
||||||
f.Meta = append(f.Meta, &picBlock)
|
|
||||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
||||||
}
|
}
|
||||||
|
f.Meta = append(f.Meta, &picBlock)
|
||||||
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
||||||
@@ -238,19 +312,12 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picBlock, err := buildPictureBlock("", coverData)
|
||||||
flacpicture.PictureTypeFrontCover,
|
|
||||||
"Front Cover",
|
|
||||||
coverData,
|
|
||||||
"image/jpeg",
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
return fmt.Errorf("failed to create picture block: %w", err)
|
||||||
} else {
|
|
||||||
picBlock := picture.Marshal()
|
|
||||||
f.Meta = append(f.Meta, &picBlock)
|
|
||||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
||||||
}
|
}
|
||||||
|
f.Meta = append(f.Meta, &picBlock)
|
||||||
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
@@ -475,33 +542,91 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExtractLyrics(filePath string) (string, error) {
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
|
lower := strings.ToLower(filePath)
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".flac") {
|
||||||
|
return extractLyricsFromFlac(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".mp3") {
|
||||||
|
meta, err := ReadID3Tags(filePath)
|
||||||
|
if err != nil || meta == nil {
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
return meta.Lyrics, nil
|
||||||
|
}
|
||||||
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||||
|
return meta.Comment, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||||
|
meta, err := ReadOggVorbisComments(filePath)
|
||||||
|
if err != nil || meta == nil {
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
return meta.Lyrics, nil
|
||||||
|
}
|
||||||
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||||
|
return meta.Comment, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("unsupported file format for lyrics extraction")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLyricsFromFlac(filePath string) (string, error) {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, meta := range f.Meta {
|
for _, meta := range f.Meta {
|
||||||
if meta.Type == flac.VorbisComment {
|
if meta.Type != flac.VorbisComment {
|
||||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
continue
|
||||||
if err != nil {
|
}
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lyrics, err := cmt.Get("LYRICS")
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err != nil {
|
||||||
return lyrics[0], nil
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
|
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||||
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("no lyrics found in file")
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func looksLikeEmbeddedLyrics(value string) bool {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if strings.Contains(lower, "[ar:") || strings.Contains(lower, "[ti:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(trimmed, "\n") && strings.Contains(trimmed, "[") && strings.Contains(trimmed, "]") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
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"`
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
|
|||||||
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
||||||
formatID := mapJumoQuality(quality)
|
formatID := mapJumoQuality(quality)
|
||||||
region := "US"
|
region := "US"
|
||||||
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
||||||
|
|
||||||
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
||||||
|
|
||||||
@@ -428,6 +428,8 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1178,6 +1180,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
})
|
})
|
||||||
var outputPath string
|
var outputPath string
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeSensitiveLogText(t *testing.T) {
|
||||||
|
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
|
||||||
|
redacted := sanitizeSensitiveLogText(input)
|
||||||
|
|
||||||
|
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
|
||||||
|
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
|
||||||
|
}
|
||||||
|
if !strings.Contains(redacted, "[REDACTED]") {
|
||||||
|
t.Fatalf("expected redaction marker in output, got: %s", redacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateExtensionAuthURL(t *testing.T) {
|
||||||
|
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
|
||||||
|
t.Fatalf("expected valid auth URL, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked := []string{
|
||||||
|
"http://accounts.example.com/oauth/authorize",
|
||||||
|
"https://user:pass@accounts.example.com/oauth/authorize",
|
||||||
|
"https://localhost/oauth/authorize",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rawURL := range blocked {
|
||||||
|
if err := validateExtensionAuthURL(rawURL); err == nil {
|
||||||
|
t.Fatalf("expected URL to be blocked: %s", rawURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
Network: []string{"api.example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
|
||||||
|
t.Fatal("expected embedded URL credentials to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildStoreExtensionDestPath(t *testing.T) {
|
||||||
|
baseDir := t.TempDir()
|
||||||
|
|
||||||
|
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isPathWithinBase(baseDir, destPath) {
|
||||||
|
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseName := filepath.Base(destPath)
|
||||||
|
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
|
||||||
|
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
|
||||||
|
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
|
||||||
|
t.Fatal("expected empty extension id to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
||||||
|
|
||||||
|
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||||
|
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||||
|
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
|
||||||
|
parsed, err := parseSpotifyURI(spotifyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := strings.TrimSpace(apiBaseURL)
|
||||||
|
if base == "" {
|
||||||
|
base = DefaultSpotFetchAPIBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parsed.Type {
|
||||||
|
case "track":
|
||||||
|
var trackResp TrackResponse
|
||||||
|
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
||||||
|
}
|
||||||
|
return trackResp, nil
|
||||||
|
case "album":
|
||||||
|
var albumResp AlbumResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
||||||
|
}
|
||||||
|
return &albumResp, nil
|
||||||
|
case "playlist":
|
||||||
|
var playlistResp PlaylistResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
||||||
|
}
|
||||||
|
return playlistResp, nil
|
||||||
|
case "artist":
|
||||||
|
var artistResp ArtistResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
||||||
|
}
|
||||||
|
return &artistResp, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1609,6 +1609,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
|||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
})
|
})
|
||||||
filename = sanitizeFilename(filename) + ext
|
filename = sanitizeFilename(filename) + ext
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ post_install do |installer|
|
|||||||
flutter_additional_ios_build_settings(target)
|
flutter_additional_ios_build_settings(target)
|
||||||
target.build_configurations.each do |config|
|
target.build_configurations.each do |config|
|
||||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
|
||||||
|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
|
||||||
|
definitions = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS']
|
||||||
|
unless definitions.include?('PERMISSION_NOTIFICATIONS=1')
|
||||||
|
definitions << 'PERMISSION_NOTIFICATIONS=1'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -83,15 +83,9 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "downloadTrack":
|
case "downloadByStrategy":
|
||||||
let requestJson = call.arguments as! String
|
let requestJson = call.arguments as! String
|
||||||
let response = GobackendDownloadTrack(requestJson, &error)
|
let response = GobackendDownloadByStrategy(requestJson, &error)
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "downloadWithFallback":
|
|
||||||
let requestJson = call.arguments as! String
|
|
||||||
let response = GobackendDownloadWithFallback(requestJson, &error)
|
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -210,6 +204,41 @@ import Gobackend // Import Go framework
|
|||||||
GobackendCleanupConnections()
|
GobackendCleanupConnections()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "downloadCoverToFile":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let coverURL = args["cover_url"] as! String
|
||||||
|
let outputPath = args["output_path"] as! String
|
||||||
|
let maxQuality = args["max_quality"] as? Bool ?? true
|
||||||
|
GobackendDownloadCoverToFile(coverURL, outputPath, maxQuality, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return "{\"success\":true}"
|
||||||
|
|
||||||
|
case "extractCoverToFile":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let audioPath = args["audio_path"] as! String
|
||||||
|
let outputPath = args["output_path"] as! String
|
||||||
|
GobackendExtractCoverToFile(audioPath, outputPath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return "{\"success\":true}"
|
||||||
|
|
||||||
|
case "fetchAndSaveLyrics":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let trackName = args["track_name"] as! String
|
||||||
|
let artistName = args["artist_name"] as! String
|
||||||
|
let spotifyId = args["spotify_id"] as! String
|
||||||
|
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||||
|
let outputPath = args["output_path"] as! String
|
||||||
|
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return "{\"success\":true}"
|
||||||
|
|
||||||
|
case "reEnrichFile":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let requestJson = args["request_json"] as? String ?? "{}"
|
||||||
|
let response = GobackendReEnrichFile(requestJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "readFileMetadata":
|
case "readFileMetadata":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let filePath = args["file_path"] as! String
|
let filePath = args["file_path"] as! String
|
||||||
@@ -479,12 +508,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "downloadWithExtensions":
|
|
||||||
let requestJson = call.arguments as! String
|
|
||||||
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "enrichTrackWithExtension":
|
case "enrichTrackWithExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
@@ -493,6 +516,12 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "removeExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
|||||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
final isFirstLaunch = ref.watch(
|
||||||
final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial));
|
settingsProvider.select((s) => s.isFirstLaunch),
|
||||||
|
);
|
||||||
|
final hasCompletedTutorial = ref.watch(
|
||||||
|
settingsProvider.select((s) => s.hasCompletedTutorial),
|
||||||
|
);
|
||||||
|
|
||||||
// Determine initial location based on app state
|
// Determine initial location based on app state
|
||||||
String initialLocation;
|
String initialLocation;
|
||||||
@@ -26,14 +30,8 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: initialLocation,
|
initialLocation: initialLocation,
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(path: '/', builder: (context, state) => const MainShell()),
|
||||||
path: '/',
|
GoRoute(path: '/setup', builder: (context, state) => const SetupScreen()),
|
||||||
builder: (context, state) => const MainShell(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/setup',
|
|
||||||
builder: (context, state) => const SetupScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/tutorial',
|
path: '/tutorial',
|
||||||
builder: (context, state) => const TutorialScreen(),
|
builder: (context, state) => const TutorialScreen(),
|
||||||
@@ -43,12 +41,17 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
class SpotiFLACApp extends ConsumerWidget {
|
class SpotiFLACApp extends ConsumerWidget {
|
||||||
const SpotiFLACApp({super.key});
|
final bool disableOverscrollEffects;
|
||||||
|
|
||||||
|
const SpotiFLACApp({super.key, this.disableOverscrollEffects = false});
|
||||||
|
|
||||||
@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));
|
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
||||||
|
final scrollBehavior = disableOverscrollEffects
|
||||||
|
? const MaterialScrollBehavior().copyWith(overscroll: false)
|
||||||
|
: null;
|
||||||
|
|
||||||
Locale? locale;
|
Locale? locale;
|
||||||
if (localeString != 'system') {
|
if (localeString != 'system') {
|
||||||
@@ -68,6 +71,7 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
theme: lightTheme,
|
theme: lightTheme,
|
||||||
darkTheme: darkTheme,
|
darkTheme: darkTheme,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
|
scrollBehavior: scrollBehavior,
|
||||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||||
themeAnimationCurve: Curves.easeInOut,
|
themeAnimationCurve: Curves.easeInOut,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.6.0';
|
static const String version = '3.6.7';
|
||||||
static const String buildNumber = '77';
|
static const String buildNumber = '81';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +17,5 @@ class AppInfo {
|
|||||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
|
||||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||||
static const String bmacUrl = 'https://buymeacoffee.com/zarzet';
|
|
||||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -928,18 +928,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Support'**
|
/// **'Support'**
|
||||||
String get aboutSupport;
|
String get aboutSupport;
|
||||||
|
|
||||||
/// Donation link
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Buy me a coffee'**
|
|
||||||
String get aboutBuyMeCoffee;
|
|
||||||
|
|
||||||
/// Subtitle for donation
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Support development on Ko-fi'**
|
|
||||||
String get aboutBuyMeCoffeeSubtitle;
|
|
||||||
|
|
||||||
/// Section for app info
|
/// Section for app info
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2164,6 +2152,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'{artist} - {title}'**
|
/// **'{artist} - {title}'**
|
||||||
String filenameHint(Object artist, Object title);
|
String filenameHint(Object artist, Object title);
|
||||||
|
|
||||||
|
/// Toggle label for showing advanced filename tags
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show advanced tags'**
|
||||||
|
String get filenameShowAdvancedTags;
|
||||||
|
|
||||||
|
/// Description for advanced filename tag toggle
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enable formatted tags for track padding and date patterns'**
|
||||||
|
String get filenameShowAdvancedTagsDescription;
|
||||||
|
|
||||||
/// Setting title - folder structure
|
/// Setting title - folder structure
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3550,6 +3550,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'Artist folders use Track Artist only'**
|
/// **'Artist folders use Track Artist only'**
|
||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
|
||||||
|
|
||||||
|
/// Setting - strip featured artists from folder name
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Primary artist only for folders'**
|
||||||
|
String get downloadUsePrimaryArtistOnly;
|
||||||
|
|
||||||
|
/// Subtitle when primary artist only is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'**
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled;
|
||||||
|
|
||||||
|
/// Subtitle when primary artist only is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Full artist string used for folder name'**
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled;
|
||||||
|
|
||||||
/// Setting - output file format
|
/// Setting - output file format
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -5062,6 +5080,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Fetch and save lyrics as .lrc file'**
|
/// **'Fetch and save lyrics as .lrc file'**
|
||||||
String get trackSaveLyricsSubtitle;
|
String get trackSaveLyricsSubtitle;
|
||||||
|
|
||||||
|
/// Snackbar while saving lyrics to file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Saving lyrics...'**
|
||||||
|
String get trackSaveLyricsProgress;
|
||||||
|
|
||||||
/// Menu action - re-embed metadata into audio file
|
/// Menu action - re-embed metadata into audio file
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -5133,6 +5157,70 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Failed: {error}'**
|
/// **'Failed: {error}'**
|
||||||
String trackSaveFailed(String error);
|
String trackSaveFailed(String error);
|
||||||
|
|
||||||
|
/// Menu item - convert audio format
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert Format'**
|
||||||
|
String get trackConvertFormat;
|
||||||
|
|
||||||
|
/// Subtitle for convert format menu item
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert to MP3 or Opus'**
|
||||||
|
String get trackConvertFormatSubtitle;
|
||||||
|
|
||||||
|
/// Title of convert bottom sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert Audio'**
|
||||||
|
String get trackConvertTitle;
|
||||||
|
|
||||||
|
/// Label for format selection
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Target Format'**
|
||||||
|
String get trackConvertTargetFormat;
|
||||||
|
|
||||||
|
/// Label for bitrate selection
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bitrate'**
|
||||||
|
String get trackConvertBitrate;
|
||||||
|
|
||||||
|
/// Confirmation dialog title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Confirm Conversion'**
|
||||||
|
String get trackConvertConfirmTitle;
|
||||||
|
|
||||||
|
/// Confirmation dialog message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.'**
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Snackbar while converting
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Converting audio...'**
|
||||||
|
String get trackConvertConverting;
|
||||||
|
|
||||||
|
/// Snackbar after successful conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Converted to {format} successfully'**
|
||||||
|
String trackConvertSuccess(String format);
|
||||||
|
|
||||||
|
/// Snackbar when conversion fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Conversion failed'**
|
||||||
|
String get trackConvertFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -457,12 +457,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1188,6 +1182,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Folder Organization';
|
String get folderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -1945,6 +1946,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2858,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,4 +2904,42 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,62 +13,62 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get appDescription =>
|
String get appDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Téléchargez des pistes Spotify en qualité sans perte de Tidal, Qobuz et Amazon Music.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHome => 'Home';
|
String get navHome => 'Accueil';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navLibrary => 'Library';
|
String get navLibrary => 'Bibliothèques';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHistory => 'History';
|
String get navHistory => 'Historique';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navSettings => 'Settings';
|
String get navSettings => 'Paramètres';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navStore => 'Store';
|
String get navStore => 'Magasin';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeTitle => 'Home';
|
String get homeTitle => 'Accueil';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSearchHint => 'Paste Spotify URL or search...';
|
String get homeSearchHint => 'Coller l\'URL Spotify ou rechercher...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String homeSearchHintExtension(String extensionName) {
|
String homeSearchHintExtension(String extensionName) {
|
||||||
return 'Search with $extensionName...';
|
return 'Rechercher avec $extensionName...';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
String get homeSupports => 'Supports: Piste, Album, Playlist, Artiste URLs';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeRecent => 'Recent';
|
String get homeRecent => 'Récent';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyTitle => 'History';
|
String get historyTitle => 'Historique';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String historyDownloading(int count) {
|
String historyDownloading(int count) {
|
||||||
return 'Downloading ($count)';
|
return 'Téléchargement ($count)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyDownloaded => 'Downloaded';
|
String get historyDownloaded => 'Téléchargé';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterAll => 'All';
|
String get historyFilterAll => 'Tous';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterAlbums => 'Albums';
|
String get historyFilterAlbums => 'Albums';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterSingles => 'Singles';
|
String get historyFilterSingles => 'Titres';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String historyTracksCount(int count) {
|
String historyTracksCount(int count) {
|
||||||
@@ -93,36 +93,37 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoDownloads => 'No download history';
|
String get historyNoDownloads => 'Pas d\'historique de téléchargement';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here';
|
String get historyNoDownloadsSubtitle =>
|
||||||
|
'Les pistes téléchargées apparaîtront ici';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoAlbums => 'No album downloads';
|
String get historyNoAlbums => 'Pas de téléchargement d\'album';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoAlbumsSubtitle =>
|
String get historyNoAlbumsSubtitle =>
|
||||||
'Download multiple tracks from an album to see them here';
|
'Téléchargez plusieurs titres d\'un album pour les voir ici';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoSingles => 'No single downloads';
|
String get historyNoSingles => 'Pas de téléchargements uniques';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoSinglesSubtitle =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Les téléchargements de pistes uniques apparaîtront ici';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historySearchHint => 'Search history...';
|
String get historySearchHint => 'Historique de recherche...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Paramètres';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownload => 'Download';
|
String get settingsDownload => 'Télécharger';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAppearance => 'Appearance';
|
String get settingsAppearance => 'Apparence';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsOptions => 'Options';
|
String get settingsOptions => 'Options';
|
||||||
@@ -131,51 +132,54 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get settingsExtensions => 'Extensions';
|
String get settingsExtensions => 'Extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAbout => 'About';
|
String get settingsAbout => 'À propos';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadTitle => 'Download';
|
String get downloadTitle => 'Télécharger';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLocation => 'Download Location';
|
String get downloadLocation => 'Télécharger Localisation';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLocationSubtitle => 'Choose where to save files';
|
String get downloadLocationSubtitle =>
|
||||||
|
'Choisissez où enregistrer des fichiers';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLocationDefault => 'Default location';
|
String get downloadLocationDefault => 'Localisation par défaut';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDefaultService => 'Default Service';
|
String get downloadDefaultService => 'Service par défaut';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDefaultServiceSubtitle => 'Service used for downloads';
|
String get downloadDefaultServiceSubtitle =>
|
||||||
|
'Service utilisé pour les téléchargements';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDefaultQuality => 'Default Quality';
|
String get downloadDefaultQuality => 'Qualité par défaut';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskQuality => 'Ask Quality Before Download';
|
String get downloadAskQuality =>
|
||||||
|
'Demandez La Qualité Avant Le Téléchargement';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskQualitySubtitle =>
|
String get downloadAskQualitySubtitle =>
|
||||||
'Show quality picker for each download';
|
'Afficher le sélecteur de qualité pour chaque téléchargement';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Filename Format';
|
String get downloadFilenameFormat => 'Nom du fichier';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Folder Organization';
|
String get downloadFolderOrganization => 'Organisation du dossier';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSeparateSingles => 'Separate Singles';
|
String get downloadSeparateSingles => 'Titres séparés';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSeparateSinglesSubtitle =>
|
String get downloadSeparateSinglesSubtitle =>
|
||||||
'Put single tracks in a separate folder';
|
'Mettre des pistes uniques dans un dossier séparé';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityBest => 'Best Available';
|
String get qualityBest => 'Meilleur Disponible';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityFlac => 'FLAC';
|
String get qualityFlac => 'FLAC';
|
||||||
@@ -187,69 +191,71 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get quality128 => '128 kbps';
|
String get quality128 => '128 kbps';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceTitle => 'Appearance';
|
String get appearanceTitle => 'Apparence';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceTheme => 'Theme';
|
String get appearanceTheme => 'Thème';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceThemeSystem => 'System';
|
String get appearanceThemeSystem => 'Système';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceThemeLight => 'Light';
|
String get appearanceThemeLight => 'Clair';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceThemeDark => 'Dark';
|
String get appearanceThemeDark => 'Sombre';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceDynamicColor => 'Dynamic Color';
|
String get appearanceDynamicColor => 'Couleur dynamique';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
|
String get appearanceDynamicColorSubtitle =>
|
||||||
|
'Utilisez les couleurs de votre fond d\'écran';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceAccentColor => 'Accent Color';
|
String get appearanceAccentColor => 'Couleur d\'accent';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryView => 'History View';
|
String get appearanceHistoryView => 'Historique Vue';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryViewList => 'List';
|
String get appearanceHistoryViewList => '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryViewGrid => 'Grid';
|
String get appearanceHistoryViewGrid => 'Grille';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsTitle => 'Options';
|
String get optionsTitle => 'Options';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSearchSource => 'Search Source';
|
String get optionsSearchSource => 'Recherche Source';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsPrimaryProvider => 'Primary Provider';
|
String get optionsPrimaryProvider => 'Fournisseur principal';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsPrimaryProviderSubtitle =>
|
String get optionsPrimaryProviderSubtitle =>
|
||||||
'Service used when searching by track name.';
|
'Service utilisé lors de la recherche par nom de piste.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String optionsUsingExtension(String extensionName) {
|
String optionsUsingExtension(String extensionName) {
|
||||||
return 'Using extension: $extensionName';
|
return 'Utilisation de l\'extension: $extensionName';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSwitchBack =>
|
String get optionsSwitchBack =>
|
||||||
'Tap Deezer or Spotify to switch back from extension';
|
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsAutoFallback => 'Auto Fallback';
|
String get optionsAutoFallback => 'Auto Fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsAutoFallbackSubtitle =>
|
String get optionsAutoFallbackSubtitle =>
|
||||||
'Try other services if download fails';
|
'Essayez d\'autres services si le téléchargement échoue';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
String get optionsUseExtensionProviders =>
|
||||||
|
'Utiliser des fournisseurs d\'extension';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||||
@@ -376,16 +382,16 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsUninstall => 'Uninstall';
|
String get extensionsUninstall => 'Désinstaller';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsSetAsSearch => 'Set as Search Provider';
|
String get extensionsSetAsSearch => 'Défini comme fournisseur de recherche';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeTitle => 'Extension Store';
|
String get storeTitle => 'Magasin d\'extension';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeSearch => 'Search extensions...';
|
String get storeSearch => 'Recherche d\'extensions...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeInstall => 'Install';
|
String get storeInstall => 'Install';
|
||||||
@@ -457,12 +463,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -573,7 +573,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get trackMetadataDuration => 'Duration';
|
String get trackMetadataDuration => 'Duration';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataQuality => 'Quality';
|
String get trackMetadataQuality => '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataPath => 'File Path';
|
String get trackMetadataPath => 'File Path';
|
||||||
@@ -585,38 +585,38 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get trackMetadataService => 'Service';
|
String get trackMetadataService => 'Service';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataPlay => 'Play';
|
String get trackMetadataPlay => 'Jouer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataShare => 'Share';
|
String get trackMetadataShare => 'Partager';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataDelete => 'Delete';
|
String get trackMetadataDelete => 'Supprimer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataRedownload => 'Re-download';
|
String get trackMetadataRedownload => 'Re-télécharger';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataOpenFolder => 'Open Folder';
|
String get trackMetadataOpenFolder => 'Dossier ouvert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupTitle => 'Welcome to SpotiFLAC';
|
String get setupTitle => 'Bienvenue chez SpotiFLAC';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupSubtitle => 'Let\'s get you started';
|
String get setupSubtitle => 'On va commencer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStoragePermission => 'Storage Permission';
|
String get setupStoragePermission => 'Permission de stockage';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStoragePermissionSubtitle =>
|
String get setupStoragePermissionSubtitle =>
|
||||||
'Required to save downloaded files';
|
'Requis pour enregistrer les fichiers téléchargés';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStoragePermissionGranted => 'Permission granted';
|
String get setupStoragePermissionGranted => 'Permission accordée';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStoragePermissionDenied => 'Permission denied';
|
String get setupStoragePermissionDenied => 'Permission refusée';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupGrantPermission => 'Grant Permission';
|
String get setupGrantPermission => 'Grant Permission';
|
||||||
@@ -741,14 +741,14 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
'Get notified when downloads complete or require attention.';
|
'Get notified when downloads complete or require attention.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupFolderSelected => 'Download Folder Selected!';
|
String get setupFolderSelected => 'Dossier de téléchargement sélectionné!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupFolderChoose => 'Choose Download Folder';
|
String get setupFolderChoose => 'Choisissez le dossier pour télécharger';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupFolderDescription =>
|
String get setupFolderDescription =>
|
||||||
'Select a folder where your downloaded music will be saved.';
|
'Sélectionnez un dossier dans lequel votre musique téléchargée sera enregistrée.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupChangeFolder => 'Change Folder';
|
String get setupChangeFolder => 'Change Folder';
|
||||||
@@ -1188,6 +1188,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Folder Organization';
|
String get folderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -1945,6 +1952,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2864,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,4 +2910,42 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,12 +457,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1188,6 +1182,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Folder Organization';
|
String get folderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -1945,6 +1946,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2858,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,4 +2904,42 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSpotifyDeprecationWarning =>
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Ekstensi';
|
String get extensionsTitle => 'Ekstensi';
|
||||||
@@ -462,12 +462,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Dukungan';
|
String get aboutSupport => 'Dukungan';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Belikan saya kopi';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Dukung pengembangan di Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'Aplikasi';
|
String get aboutApp => 'Aplikasi';
|
||||||
|
|
||||||
@@ -1194,6 +1188,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Aktifkan tag format untuk padding nomor lagu dan pola tanggal';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Organisasi Folder';
|
String get folderOrganization => 'Organisasi Folder';
|
||||||
|
|
||||||
@@ -1947,16 +1948,26 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders =>
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
'Gunakan Album Artist untuk folder';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
'Folder artis memakai Album Artist jika tersedia';
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Folder artis hanya memakai Track Artist';
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Simpan Format';
|
String get downloadSaveFormat => 'Simpan Format';
|
||||||
@@ -2195,10 +2206,10 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recentEmpty => 'Belum ada item terbaru';
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recentShowAllDownloads => 'Tampilkan Semua Download';
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
@@ -2307,10 +2318,10 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsCache => 'Penyimpanan & Cache';
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsCacheSubtitle => 'Lihat ukuran dan bersihkan data cache';
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryTitle => 'Local Library';
|
String get libraryTitle => 'Local Library';
|
||||||
@@ -2585,221 +2596,219 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
|
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeDesc =>
|
String get tutorialWelcomeDesc =>
|
||||||
'Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
|
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip1 =>
|
String get tutorialWelcomeTip1 =>
|
||||||
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
|
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music';
|
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
'Metadata, cover art, dan lirik otomatis tertanam';
|
'Automatic metadata, cover art, and lyrics embedding';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSearchTitle => 'Mencari Musik';
|
String get tutorialSearchTitle => 'Finding Music';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSearchDesc =>
|
String get tutorialSearchDesc =>
|
||||||
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
|
'There are two easy ways to find music you want to download.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSearchTip1 =>
|
String get tutorialSearchTip1 =>
|
||||||
'Tempel URL Spotify atau Deezer langsung di kotak pencarian';
|
'Paste a Spotify or Deezer URL directly in the search box';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSearchTip2 =>
|
String get tutorialSearchTip2 =>
|
||||||
'Atau ketik nama lagu, artis, atau album untuk mencari';
|
'Or type the song name, artist, or album to search';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSearchTip3 =>
|
String get tutorialSearchTip3 =>
|
||||||
'Mendukung lagu, album, playlist, dan halaman artis';
|
'Supports tracks, albums, playlists, and artist pages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadTitle => 'Mengunduh Musik';
|
String get tutorialDownloadTitle => 'Downloading Music';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadDesc =>
|
String get tutorialDownloadDesc =>
|
||||||
'Mengunduh musik itu mudah dan cepat. Begini caranya.';
|
'Downloading music is simple and fast. Here\'s how it works.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadTip1 =>
|
String get tutorialDownloadTip1 =>
|
||||||
'Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh';
|
'Tap the download button next to any track to start downloading';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadTip2 =>
|
String get tutorialDownloadTip2 =>
|
||||||
'Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)';
|
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadTip3 =>
|
String get tutorialDownloadTip3 =>
|
||||||
'Unduh seluruh album atau playlist dengan satu ketukan';
|
'Download entire albums or playlists with one tap';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryTitle => 'Perpustakaan Anda';
|
String get tutorialLibraryTitle => 'Your Library';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryDesc =>
|
String get tutorialLibraryDesc =>
|
||||||
'Semua musik yang Anda unduh terorganisir di tab Perpustakaan.';
|
'All your downloaded music is organized in the Library tab.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryTip1 =>
|
String get tutorialLibraryTip1 =>
|
||||||
'Lihat progres unduhan dan antrian di tab Perpustakaan';
|
'View download progress and queue in the Library tab';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryTip2 =>
|
String get tutorialLibraryTip2 =>
|
||||||
'Ketuk lagu mana pun untuk memutarnya dengan pemutar musik';
|
'Tap any track to play it with your music player';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryTip3 =>
|
String get tutorialLibraryTip3 =>
|
||||||
'Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik';
|
'Switch between list and grid view for better browsing';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTitle => 'Ekstensi';
|
String get tutorialExtensionsTitle => 'Extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsDesc =>
|
String get tutorialExtensionsDesc =>
|
||||||
'Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.';
|
'Extend the app\'s capabilities with community extensions.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Jelajahi tab Toko untuk menemukan ekstensi berguna';
|
'Browse the Store tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
'Tambahkan provider unduhan atau sumber pencarian baru';
|
'Add new download providers or search sources';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip3 =>
|
String get tutorialExtensionsTip3 =>
|
||||||
'Dapatkan lirik, metadata lebih baik, dan fitur lainnya';
|
'Get lyrics, enhanced metadata, and more features';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsTitle => 'Sesuaikan Pengalaman Anda';
|
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsDesc =>
|
String get tutorialSettingsDesc =>
|
||||||
'Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.';
|
'Personalize the app in Settings to match your preferences.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsTip1 =>
|
String get tutorialSettingsTip1 =>
|
||||||
'Ubah lokasi unduhan dan organisasi folder';
|
'Change download location and folder organization';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsTip2 =>
|
String get tutorialSettingsTip2 =>
|
||||||
'Atur kualitas audio dan preferensi format default';
|
'Set default audio quality and format preferences';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsTip3 => 'Sesuaikan tema dan tampilan aplikasi';
|
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialReadyMessage =>
|
String get tutorialReadyMessage =>
|
||||||
'Anda siap! Mulai unduh musik favorit Anda sekarang.';
|
'You\'re all set! Start downloading your favorite music now.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExample => 'CONTOH';
|
String get tutorialExample => 'EXAMPLE';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryForceFullScan => 'Pindai Ulang Penuh';
|
String get libraryForceFullScan => 'Force Full Scan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryForceFullScanSubtitle =>
|
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||||
'Pindai ulang semua file, abaikan cache';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloads => 'Bersihkan Entri Unduhan Tidak Valid';
|
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsSubtitle =>
|
String get cleanupOrphanedDownloadsSubtitle =>
|
||||||
'Hapus entri riwayat untuk file yang tidak ada lagi';
|
'Remove history entries for files that no longer exist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cleanupOrphanedDownloadsResult(int count) {
|
String cleanupOrphanedDownloadsResult(int count) {
|
||||||
return 'Menghapus $count entri unduhan tidak valid dari riwayat';
|
return 'Removed $count orphaned entries from history';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsNone =>
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
'Tidak ada entri unduhan tidak valid';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTitle => 'Penyimpanan & Cache';
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheSummaryTitle => 'Ringkasan cache';
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheSummarySubtitle =>
|
String get cacheSummarySubtitle =>
|
||||||
'Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.';
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheEstimatedTotal(String size) {
|
String cacheEstimatedTotal(String size) {
|
||||||
return 'Estimasi penggunaan cache: $size';
|
return 'Estimated cache usage: $size';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheSectionStorage => 'Data Cache';
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheSectionMaintenance => 'Perawatan';
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheAppDirectory => 'Direktori cache aplikasi';
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheAppDirectoryDesc =>
|
String get cacheAppDirectoryDesc =>
|
||||||
'Respons HTTP, data WebView, dan data sementara aplikasi.';
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTempDirectory => 'Direktori sementara';
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTempDirectoryDesc =>
|
String get cacheTempDirectoryDesc =>
|
||||||
'File sementara dari proses download dan konversi audio.';
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCoverImage => 'Cache gambar cover';
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCoverImageDesc =>
|
String get cacheCoverImageDesc =>
|
||||||
'Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.';
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheLibraryCover => 'Cache cover library';
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheLibraryCoverDesc =>
|
String get cacheLibraryCoverDesc =>
|
||||||
'Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.';
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheExploreFeed => 'Cache feed Explore';
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheExploreFeedDesc =>
|
String get cacheExploreFeedDesc =>
|
||||||
'Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.';
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTrackLookup => 'Cache pencocokan lagu';
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTrackLookupDesc =>
|
String get cacheTrackLookupDesc =>
|
||||||
'Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.';
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCleanupUnusedDesc =>
|
String get cacheCleanupUnusedDesc =>
|
||||||
'Hapus entri riwayat download dan library yang filenya sudah tidak ada.';
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheNoData => 'Tidak ada data cache';
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheSizeWithFiles(String size, int count) {
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
return '$size dalam $count file';
|
return '$size in $count files';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2809,103 +2818,141 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheEntries(int count) {
|
String cacheEntries(int count) {
|
||||||
return '$count entri';
|
return '$count entries';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheClearSuccess(String target) {
|
String cacheClearSuccess(String target) {
|
||||||
return 'Berhasil dibersihkan: $target';
|
return 'Cleared: $target';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheClearConfirmTitle => 'Bersihkan cache?';
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheClearConfirmMessage(String target) {
|
String cacheClearConfirmMessage(String target) {
|
||||||
return 'Ini akan membersihkan data cache untuk $target. File musik yang sudah diunduh tidak akan dihapus.';
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheClearAllConfirmTitle => 'Bersihkan semua cache?';
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheClearAllConfirmMessage =>
|
String get cacheClearAllConfirmMessage =>
|
||||||
'Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.';
|
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheClearAll => 'Bersihkan semua cache';
|
String get cacheClearAll => 'Clear all cache';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCleanupUnused => 'Bersihkan data tidak terpakai';
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCleanupUnusedSubtitle =>
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
'Hapus riwayat unduhan yatim dan entri library yang file-nya hilang';
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
return 'Pembersihan selesai: $downloadCount unduhan yatim, $libraryCount entri library hilang';
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefreshStats => 'Segarkan statistik';
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackSaveCoverArt => 'Simpan Cover Art';
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackSaveCoverArtSubtitle =>
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
'Simpan cover album sebagai file .jpg';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackSaveLyrics => 'Simpan Lirik (.lrc)';
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle =>
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
'Ambil dan simpan lirik sebagai file .lrc';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Perkaya Ulang Metadata';
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrichSubtitle =>
|
String get trackReEnrichSubtitle =>
|
||||||
'Tanamkan ulang metadata tanpa mengunduh ulang';
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Cari metadata dari internet dan tanamkan ke file';
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Edit Metadata';
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String trackCoverSaved(String fileName) {
|
String trackCoverSaved(String fileName) {
|
||||||
return 'Cover art disimpan ke $fileName';
|
return 'Cover art saved to $fileName';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCoverNoSource => 'Tidak ada sumber cover art';
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String trackLyricsSaved(String fileName) {
|
String trackLyricsSaved(String fileName) {
|
||||||
return 'Lirik disimpan ke $fileName';
|
return 'Lyrics saved to $fileName';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrichProgress => 'Memperkaya ulang metadata...';
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrichSearching => 'Mencari metadata dari internet...';
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrichSuccess => 'Metadata berhasil diperkaya ulang';
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed =>
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
'Gagal menanamkan metadata via FFmpeg';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Gagal: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -453,12 +453,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'サポート';
|
String get aboutSupport => 'サポート';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'コーヒーを買ってください';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'アプリ';
|
String get aboutApp => 'アプリ';
|
||||||
|
|
||||||
@@ -1182,6 +1176,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'フォルダ構成';
|
String get folderOrganization => 'フォルダ構成';
|
||||||
|
|
||||||
@@ -1933,6 +1934,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => '形式を保存';
|
String get downloadSaveFormat => '形式を保存';
|
||||||
|
|
||||||
@@ -2832,6 +2844,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2875,4 +2890,42 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get appDescription =>
|
String get appDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Spotify 트랙을 Tidal, Qobuz, Amazon Music에서 무손실 음질로 다운로드하세요.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHome => 'Home';
|
String get navHome => 'Home';
|
||||||
@@ -34,32 +34,32 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get homeTitle => 'Home';
|
String get homeTitle => 'Home';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSearchHint => 'Paste Spotify URL or search...';
|
String get homeSearchHint => 'Spotify URL을 붙여 넣거나 검색';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String homeSearchHintExtension(String extensionName) {
|
String homeSearchHintExtension(String extensionName) {
|
||||||
return 'Search with $extensionName...';
|
return '$extensionName에서 검색';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeRecent => 'Recent';
|
String get homeRecent => '최근 기록';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyTitle => 'History';
|
String get historyTitle => '기록';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String historyDownloading(int count) {
|
String historyDownloading(int count) {
|
||||||
return 'Downloading ($count)';
|
return '다운로드 중... $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyDownloaded => 'Downloaded';
|
String get historyDownloaded => '다운로드 목록';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterAll => 'All';
|
String get historyFilterAll => 'All';
|
||||||
@@ -75,7 +75,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: '$count tracks',
|
other: '${count}tracks',
|
||||||
one: '1 track',
|
one: '1 track',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
@@ -245,14 +245,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get optionsAutoFallback => 'Auto Fallback';
|
String get optionsAutoFallback => 'Auto Fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsAutoFallbackSubtitle =>
|
String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도';
|
||||||
'Try other services if download fails';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
||||||
@@ -457,12 +456,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1188,6 +1181,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Folder Organization';
|
String get folderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -1945,6 +1945,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2857,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,4 +2903,42 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,12 +457,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -1188,6 +1182,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Folder Organization';
|
String get folderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -1945,6 +1946,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2846,6 +2858,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2889,4 +2904,42 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,12 +464,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Destek';
|
String get aboutSupport => 'Destek';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Bana bir kahve ısmarla';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi üzerinden uygulamayı destekle';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'Uygulama';
|
String get aboutApp => 'Uygulama';
|
||||||
|
|
||||||
@@ -1195,6 +1189,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Klasör Organizasyonu';
|
String get folderOrganization => 'Klasör Organizasyonu';
|
||||||
|
|
||||||
@@ -1960,6 +1961,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2861,6 +2873,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackReEnrich => 'Re-enrich Metadata';
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
@@ -2904,4 +2919,42 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,10 +326,6 @@
|
|||||||
"@aboutSocial": {"description": "Section for social links"},
|
"@aboutSocial": {"description": "Section for social links"},
|
||||||
"aboutSupport": "Support",
|
"aboutSupport": "Support",
|
||||||
"@aboutSupport": {"description": "Section for support/donation links"},
|
"@aboutSupport": {"description": "Section for support/donation links"},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {"description": "Donation link"},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {"description": "Subtitle for donation"},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {"description": "Section for app info"},
|
"@aboutApp": {"description": "Section for app info"},
|
||||||
"aboutVersion": "Version",
|
"aboutVersion": "Version",
|
||||||
@@ -878,6 +874,14 @@
|
|||||||
"@filenameAvailablePlaceholders": {"description": "Label for placeholder list"},
|
"@filenameAvailablePlaceholders": {"description": "Label for placeholder list"},
|
||||||
"filenameHint": "{artist} - {title}",
|
"filenameHint": "{artist} - {title}",
|
||||||
"@filenameHint": {"description": "Default filename format hint"},
|
"@filenameHint": {"description": "Default filename format hint"},
|
||||||
|
"filenameShowAdvancedTags": "Show advanced tags",
|
||||||
|
"@filenameShowAdvancedTags": {
|
||||||
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
|
},
|
||||||
|
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||||
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
|
"description": "Description for advanced filename tag toggle"
|
||||||
|
},
|
||||||
|
|
||||||
"folderOrganization": "Folder Organization",
|
"folderOrganization": "Folder Organization",
|
||||||
"@folderOrganization": {"description": "Setting title - folder structure"},
|
"@folderOrganization": {"description": "Setting title - folder structure"},
|
||||||
@@ -1431,6 +1435,12 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
|
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
|
||||||
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
|
||||||
|
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
||||||
|
"@downloadUsePrimaryArtistOnly": {"description": "Setting - strip featured artists from folder name"},
|
||||||
|
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
|
||||||
|
"@downloadUsePrimaryArtistOnlyEnabled": {"description": "Subtitle when primary artist only is enabled"},
|
||||||
|
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
||||||
|
"@downloadUsePrimaryArtistOnlyDisabled": {"description": "Subtitle when primary artist only is disabled"},
|
||||||
"downloadSaveFormat": "Save Format",
|
"downloadSaveFormat": "Save Format",
|
||||||
"@downloadSaveFormat": {"description": "Setting - output file format"},
|
"@downloadSaveFormat": {"description": "Setting - output file format"},
|
||||||
"downloadSelectService": "Select Service",
|
"downloadSelectService": "Select Service",
|
||||||
@@ -2144,6 +2154,8 @@
|
|||||||
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
||||||
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
|
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
|
||||||
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
||||||
|
"trackSaveLyricsProgress": "Saving lyrics...",
|
||||||
|
"@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"},
|
||||||
"trackReEnrich": "Re-enrich Metadata",
|
"trackReEnrich": "Re-enrich Metadata",
|
||||||
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
|
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
|
||||||
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
|
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
|
||||||
@@ -2182,5 +2194,38 @@
|
|||||||
"placeholders": {
|
"placeholders": {
|
||||||
"error": {"type": "String"}
|
"error": {"type": "String"}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
"trackConvertFormat": "Convert Format",
|
||||||
|
"@trackConvertFormat": {"description": "Menu item - convert audio format"},
|
||||||
|
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||||
|
"@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"},
|
||||||
|
"trackConvertTitle": "Convert Audio",
|
||||||
|
"@trackConvertTitle": {"description": "Title of convert bottom sheet"},
|
||||||
|
"trackConvertTargetFormat": "Target Format",
|
||||||
|
"@trackConvertTargetFormat": {"description": "Label for format selection"},
|
||||||
|
"trackConvertBitrate": "Bitrate",
|
||||||
|
"@trackConvertBitrate": {"description": "Label for bitrate selection"},
|
||||||
|
"trackConvertConfirmTitle": "Confirm Conversion",
|
||||||
|
"@trackConvertConfirmTitle": {"description": "Confirmation dialog title"},
|
||||||
|
"trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.",
|
||||||
|
"@trackConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {"type": "String"},
|
||||||
|
"targetFormat": {"type": "String"},
|
||||||
|
"bitrate": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertConverting": "Converting audio...",
|
||||||
|
"@trackConvertConverting": {"description": "Snackbar while converting"},
|
||||||
|
"trackConvertSuccess": "Converted to {format} successfully",
|
||||||
|
"@trackConvertSuccess": {
|
||||||
|
"description": "Snackbar after successful conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"format": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertFailed": "Conversion failed",
|
||||||
|
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
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:path_provider/path_provider.dart';
|
||||||
@@ -11,12 +12,70 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
|||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final runtimeProfile = await _resolveRuntimeProfile();
|
||||||
|
_configureImageCache(runtimeProfile);
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
|
ProviderScope(
|
||||||
|
child: _EagerInitialization(
|
||||||
|
child: SpotiFLACApp(
|
||||||
|
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<_RuntimeProfile> _resolveRuntimeProfile() async {
|
||||||
|
const defaults = _RuntimeProfile(
|
||||||
|
imageCacheMaximumSize: 240,
|
||||||
|
imageCacheMaximumSizeBytes: 60 << 20,
|
||||||
|
disableOverscrollEffects: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!Platform.isAndroid) return defaults;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||||
|
final isArm32Only = androidInfo.supported64BitAbis.isEmpty;
|
||||||
|
final isLowRamDevice =
|
||||||
|
androidInfo.isLowRamDevice || androidInfo.physicalRamSize <= 2500;
|
||||||
|
|
||||||
|
if (!isArm32Only && !isLowRamDevice) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _RuntimeProfile(
|
||||||
|
imageCacheMaximumSize: 120,
|
||||||
|
imageCacheMaximumSizeBytes: 24 << 20,
|
||||||
|
disableOverscrollEffects: true,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to resolve runtime profile: $e');
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _configureImageCache(_RuntimeProfile runtimeProfile) {
|
||||||
|
final imageCache = PaintingBinding.instance.imageCache;
|
||||||
|
// Keep memory cache bounded so cover-heavy pages don't retain too many
|
||||||
|
// full-resolution images simultaneously.
|
||||||
|
imageCache.maximumSize = runtimeProfile.imageCacheMaximumSize;
|
||||||
|
imageCache.maximumSizeBytes = runtimeProfile.imageCacheMaximumSizeBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RuntimeProfile {
|
||||||
|
final int imageCacheMaximumSize;
|
||||||
|
final int imageCacheMaximumSizeBytes;
|
||||||
|
final bool disableOverscrollEffects;
|
||||||
|
|
||||||
|
const _RuntimeProfile({
|
||||||
|
required this.imageCacheMaximumSize,
|
||||||
|
required this.imageCacheMaximumSizeBytes,
|
||||||
|
required this.disableOverscrollEffects,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 ConsumerStatefulWidget {
|
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||||
const _EagerInitialization({required this.child});
|
const _EagerInitialization({required this.child});
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class AppSettings {
|
|||||||
final bool hasSearchedBefore;
|
final bool hasSearchedBefore;
|
||||||
final String folderOrganization;
|
final String folderOrganization;
|
||||||
final bool useAlbumArtistForFolders;
|
final bool useAlbumArtistForFolders;
|
||||||
|
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
|
||||||
|
final bool filterContributingArtistsInAlbumArtist;
|
||||||
final String historyViewMode;
|
final String historyViewMode;
|
||||||
final String historyFilterMode;
|
final String historyFilterMode;
|
||||||
final bool askQualityBeforeDownload;
|
final bool askQualityBeforeDownload;
|
||||||
@@ -35,18 +37,24 @@ class AppSettings {
|
|||||||
final bool showExtensionStore;
|
final bool showExtensionStore;
|
||||||
final String locale;
|
final String locale;
|
||||||
final String lyricsMode;
|
final String lyricsMode;
|
||||||
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
final String
|
||||||
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||||
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
final bool
|
||||||
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||||
|
final bool
|
||||||
|
autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
||||||
|
final String
|
||||||
|
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
||||||
|
|
||||||
// Local Library Settings
|
// Local Library Settings
|
||||||
final bool localLibraryEnabled; // Enable local library scanning
|
final bool localLibraryEnabled; // Enable local library scanning
|
||||||
final String localLibraryPath; // Path to scan for audio files
|
final String localLibraryPath; // Path to scan for audio files
|
||||||
final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
final bool
|
||||||
|
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||||
|
|
||||||
// Tutorial/Onboarding
|
// Tutorial/Onboarding
|
||||||
final bool hasCompletedTutorial; // Track if user has completed the app tutorial
|
final bool
|
||||||
|
hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -65,6 +73,8 @@ class AppSettings {
|
|||||||
this.hasSearchedBefore = false,
|
this.hasSearchedBefore = false,
|
||||||
this.folderOrganization = 'none',
|
this.folderOrganization = 'none',
|
||||||
this.useAlbumArtistForFolders = true,
|
this.useAlbumArtistForFolders = true,
|
||||||
|
this.usePrimaryArtistOnly = false,
|
||||||
|
this.filterContributingArtistsInAlbumArtist = false,
|
||||||
this.historyViewMode = 'grid',
|
this.historyViewMode = 'grid',
|
||||||
this.historyFilterMode = 'all',
|
this.historyFilterMode = 'all',
|
||||||
this.askQualityBeforeDownload = true,
|
this.askQualityBeforeDownload = true,
|
||||||
@@ -109,6 +119,8 @@ class AppSettings {
|
|||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
bool? useAlbumArtistForFolders,
|
bool? useAlbumArtistForFolders,
|
||||||
|
bool? usePrimaryArtistOnly,
|
||||||
|
bool? filterContributingArtistsInAlbumArtist,
|
||||||
String? historyViewMode,
|
String? historyViewMode,
|
||||||
String? historyFilterMode,
|
String? historyFilterMode,
|
||||||
bool? askQualityBeforeDownload,
|
bool? askQualityBeforeDownload,
|
||||||
@@ -154,16 +166,25 @@ class AppSettings {
|
|||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
useAlbumArtistForFolders:
|
useAlbumArtistForFolders:
|
||||||
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
||||||
|
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
|
||||||
|
filterContributingArtistsInAlbumArtist:
|
||||||
|
filterContributingArtistsInAlbumArtist ??
|
||||||
|
this.filterContributingArtistsInAlbumArtist,
|
||||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
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,
|
useExtensionProviders:
|
||||||
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
|
useExtensionProviders ?? this.useExtensionProviders,
|
||||||
|
searchProvider: clearSearchProvider
|
||||||
|
? null
|
||||||
|
: (searchProvider ?? this.searchProvider),
|
||||||
separateSingles: separateSingles ?? this.separateSingles,
|
separateSingles: separateSingles ?? this.separateSingles,
|
||||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||||
@@ -171,12 +192,14 @@ class AppSettings {
|
|||||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||||
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
autoExportFailedDownloads:
|
||||||
|
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||||
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
||||||
// Local Library
|
// Local Library
|
||||||
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
||||||
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
||||||
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
localLibraryShowDuplicates:
|
||||||
|
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||||
// Tutorial
|
// Tutorial
|
||||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ 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',
|
||||||
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
||||||
|
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
|
||||||
|
filterContributingArtistsInAlbumArtist:
|
||||||
|
json['filterContributingArtistsInAlbumArtist'] as bool? ?? false,
|
||||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
@@ -70,6 +73,9 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||||
|
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||||
|
'filterContributingArtistsInAlbumArtist':
|
||||||
|
instance.filterContributingArtistsInAlbumArtist,
|
||||||
'historyViewMode': instance.historyViewMode,
|
'historyViewMode': instance.historyViewMode,
|
||||||
'historyFilterMode': instance.historyFilterMode,
|
'historyFilterMode': instance.historyFilterMode,
|
||||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import 'dart:io';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/services/history_database.dart';
|
import 'package:spotiflac_android/services/history_database.dart';
|
||||||
import 'package:spotiflac_android/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
|
import 'package:spotiflac_android/services/notification_service.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/utils/logger.dart';
|
||||||
|
|
||||||
@@ -12,6 +14,7 @@ final _log = AppLogger('LocalLibrary');
|
|||||||
|
|
||||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||||
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
||||||
|
final _prefs = SharedPreferences.getInstance();
|
||||||
|
|
||||||
class LocalLibraryState {
|
class LocalLibraryState {
|
||||||
final List<LocalLibraryItem> items;
|
final List<LocalLibraryItem> items;
|
||||||
@@ -24,9 +27,9 @@ class LocalLibraryState {
|
|||||||
final bool scanWasCancelled;
|
final bool scanWasCancelled;
|
||||||
final DateTime? lastScannedAt;
|
final DateTime? lastScannedAt;
|
||||||
final int excludedDownloadedCount;
|
final int excludedDownloadedCount;
|
||||||
final Set<String> _isrcSet;
|
|
||||||
final Set<String> _trackKeySet;
|
final Set<String> _trackKeySet;
|
||||||
final Map<String, LocalLibraryItem> _byIsrc;
|
final Map<String, LocalLibraryItem> _byIsrc;
|
||||||
|
final Map<String, LocalLibraryItem> _byTrackKey;
|
||||||
|
|
||||||
LocalLibraryState({
|
LocalLibraryState({
|
||||||
this.items = const [],
|
this.items = const [],
|
||||||
@@ -39,18 +42,22 @@ class LocalLibraryState {
|
|||||||
this.scanWasCancelled = false,
|
this.scanWasCancelled = false,
|
||||||
this.lastScannedAt,
|
this.lastScannedAt,
|
||||||
this.excludedDownloadedCount = 0,
|
this.excludedDownloadedCount = 0,
|
||||||
}) : _isrcSet = items
|
Set<String>? trackKeySet,
|
||||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
Map<String, LocalLibraryItem>? byIsrc,
|
||||||
.map((item) => item.isrc!)
|
Map<String, LocalLibraryItem>? byTrackKey,
|
||||||
.toSet(),
|
}) : _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(),
|
||||||
_trackKeySet = items.map((item) => item.matchKey).toSet(),
|
_byIsrc =
|
||||||
_byIsrc = Map.fromEntries(
|
byIsrc ??
|
||||||
items
|
Map.fromEntries(
|
||||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
items
|
||||||
.map((item) => MapEntry(item.isrc!, item)),
|
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||||
);
|
.map((item) => MapEntry(item.isrc!, item)),
|
||||||
|
),
|
||||||
|
_byTrackKey =
|
||||||
|
byTrackKey ??
|
||||||
|
Map.fromEntries(items.map((item) => MapEntry(item.matchKey, item)));
|
||||||
|
|
||||||
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
|
bool hasIsrc(String isrc) => _byIsrc.containsKey(isrc);
|
||||||
|
|
||||||
bool hasTrack(String trackName, String artistName) {
|
bool hasTrack(String trackName, String artistName) {
|
||||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||||
@@ -61,7 +68,7 @@ class LocalLibraryState {
|
|||||||
|
|
||||||
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
|
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
|
||||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||||
return items.where((item) => item.matchKey == key).firstOrNull;
|
return _byTrackKey[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
||||||
@@ -86,8 +93,11 @@ class LocalLibraryState {
|
|||||||
DateTime? lastScannedAt,
|
DateTime? lastScannedAt,
|
||||||
int? excludedDownloadedCount,
|
int? excludedDownloadedCount,
|
||||||
}) {
|
}) {
|
||||||
|
final nextItems = items ?? this.items;
|
||||||
|
final keepDerivedIndex = identical(nextItems, this.items);
|
||||||
|
|
||||||
return LocalLibraryState(
|
return LocalLibraryState(
|
||||||
items: items ?? this.items,
|
items: nextItems,
|
||||||
isScanning: isScanning ?? this.isScanning,
|
isScanning: isScanning ?? this.isScanning,
|
||||||
scanProgress: scanProgress ?? this.scanProgress,
|
scanProgress: scanProgress ?? this.scanProgress,
|
||||||
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
||||||
@@ -98,6 +108,9 @@ class LocalLibraryState {
|
|||||||
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
||||||
excludedDownloadedCount:
|
excludedDownloadedCount:
|
||||||
excludedDownloadedCount ?? this.excludedDownloadedCount,
|
excludedDownloadedCount ?? this.excludedDownloadedCount,
|
||||||
|
trackKeySet: keepDerivedIndex ? _trackKeySet : null,
|
||||||
|
byIsrc: keepDerivedIndex ? _byIsrc : null,
|
||||||
|
byTrackKey: keepDerivedIndex ? _byTrackKey : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,11 +118,13 @@ class LocalLibraryState {
|
|||||||
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||||
final LibraryDatabase _db = LibraryDatabase.instance;
|
final LibraryDatabase _db = LibraryDatabase.instance;
|
||||||
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
||||||
|
final NotificationService _notificationService = NotificationService();
|
||||||
static const _progressPollingInterval = Duration(milliseconds: 800);
|
static const _progressPollingInterval = Duration(milliseconds: 800);
|
||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
bool _scanCancelRequested = false;
|
bool _scanCancelRequested = false;
|
||||||
int _progressPollingErrorCount = 0;
|
int _progressPollingErrorCount = 0;
|
||||||
|
bool _isProgressPollingInFlight = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
LocalLibraryState build() {
|
LocalLibraryState build() {
|
||||||
@@ -128,13 +143,17 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_isLoaded = true;
|
_isLoaded = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final jsonList = await _db.getAll();
|
final dbItemsFuture = _db.getAll();
|
||||||
final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
final prefsFuture = _prefs;
|
||||||
|
final jsonList = await dbItemsFuture;
|
||||||
|
final items = jsonList
|
||||||
|
.map((e) => LocalLibraryItem.fromJson(e))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
DateTime? lastScannedAt;
|
DateTime? lastScannedAt;
|
||||||
var excludedDownloadedCount = 0;
|
var excludedDownloadedCount = 0;
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await prefsFuture;
|
||||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
||||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
||||||
@@ -164,6 +183,58 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
await _loadFromDatabase();
|
await _loadFromDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<String> _buildPathMatchKeys(String? filePath) {
|
||||||
|
final raw = filePath?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) return const {};
|
||||||
|
|
||||||
|
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
|
||||||
|
final keys = <String>{cleaned};
|
||||||
|
|
||||||
|
void addNormalized(String value) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty) return;
|
||||||
|
keys.add(trimmed);
|
||||||
|
keys.add(trimmed.toLowerCase());
|
||||||
|
if (trimmed.contains('\\')) {
|
||||||
|
final slash = trimmed.replaceAll('\\', '/');
|
||||||
|
keys.add(slash);
|
||||||
|
keys.add(slash.toLowerCase());
|
||||||
|
}
|
||||||
|
if (trimmed.contains('%')) {
|
||||||
|
try {
|
||||||
|
final decoded = Uri.decodeFull(trimmed);
|
||||||
|
keys.add(decoded);
|
||||||
|
keys.add(decoded.toLowerCase());
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addNormalized(cleaned);
|
||||||
|
|
||||||
|
if (cleaned.startsWith('content://')) {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse(cleaned);
|
||||||
|
addNormalized(uri.toString());
|
||||||
|
addNormalized(uri.replace(query: null, fragment: null).toString());
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
|
||||||
|
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final candidateKeys = _buildPathMatchKeys(filePath);
|
||||||
|
for (final key in candidateKeys) {
|
||||||
|
if (downloadedPathKeys.contains(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> startScan(
|
Future<void> startScan(
|
||||||
String folderPath, {
|
String folderPath, {
|
||||||
bool forceFullScan = false,
|
bool forceFullScan = false,
|
||||||
@@ -186,6 +257,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
scanErrorCount: 0,
|
scanErrorCount: 0,
|
||||||
scanWasCancelled: false,
|
scanWasCancelled: false,
|
||||||
);
|
);
|
||||||
|
await _showScanProgressNotification(
|
||||||
|
progress: 0,
|
||||||
|
scannedFiles: 0,
|
||||||
|
totalFiles: 0,
|
||||||
|
currentFile: null,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final appSupportDir = await getApplicationSupportDirectory();
|
final appSupportDir = await getApplicationSupportDirectory();
|
||||||
@@ -201,10 +278,26 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
try {
|
try {
|
||||||
final isSaf = folderPath.startsWith('content://');
|
final isSaf = folderPath.startsWith('content://');
|
||||||
|
|
||||||
// Get all file paths from download history to exclude them
|
// Get all file paths from download history to exclude them.
|
||||||
|
// Merge DB + in-memory state to avoid race when a fresh download has not
|
||||||
|
// been flushed to SQLite yet.
|
||||||
final downloadedPaths = await _historyDb.getAllFilePaths();
|
final downloadedPaths = await _historyDb.getAllFilePaths();
|
||||||
|
final inMemoryHistoryPaths = ref
|
||||||
|
.read(downloadHistoryProvider)
|
||||||
|
.items
|
||||||
|
.map((item) => item.filePath)
|
||||||
|
.where((path) => path.isNotEmpty);
|
||||||
|
final allHistoryPaths = <String>{
|
||||||
|
...downloadedPaths,
|
||||||
|
...inMemoryHistoryPaths,
|
||||||
|
};
|
||||||
|
final downloadedPathKeys = <String>{};
|
||||||
|
for (final path in allHistoryPaths) {
|
||||||
|
downloadedPathKeys.addAll(_buildPathMatchKeys(path));
|
||||||
|
}
|
||||||
_log.i(
|
_log.i(
|
||||||
'Excluding ${downloadedPaths.length} downloaded files from library scan',
|
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
|
||||||
|
'(${downloadedPathKeys.length} path keys)',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (forceFullScan) {
|
if (forceFullScan) {
|
||||||
@@ -214,6 +307,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
: await PlatformBridge.scanLibraryFolder(folderPath);
|
: await PlatformBridge.scanLibraryFolder(folderPath);
|
||||||
if (_scanCancelRequested) {
|
if (_scanCancelRequested) {
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||||
|
await _showScanCancelledNotification();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +316,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
for (final json in results) {
|
for (final json in results) {
|
||||||
final filePath = json['filePath'] as String?;
|
final filePath = json['filePath'] as String?;
|
||||||
// Skip files that are already in download history
|
// Skip files that are already in download history
|
||||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
|
||||||
skippedDownloads++;
|
skippedDownloads++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -259,6 +353,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
'Full scan complete: ${items.length} tracks found, '
|
'Full scan complete: ${items.length} tracks found, '
|
||||||
'$skippedDownloads already in downloads',
|
'$skippedDownloads already in downloads',
|
||||||
);
|
);
|
||||||
|
await _showScanCompleteNotification(
|
||||||
|
totalTracks: items.length,
|
||||||
|
excludedDownloadedCount: skippedDownloads,
|
||||||
|
errorCount: state.scanErrorCount,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Incremental scan path - only scans new/modified files
|
// Incremental scan path - only scans new/modified files
|
||||||
final existingFiles = await _db.getFileModTimes();
|
final existingFiles = await _db.getFileModTimes();
|
||||||
@@ -292,6 +391,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
|
|
||||||
if (_scanCancelRequested) {
|
if (_scanCancelRequested) {
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||||
|
await _showScanCancelledNotification();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +428,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
for (final json in scannedList) {
|
for (final json in scannedList) {
|
||||||
final map = json as Map<String, dynamic>;
|
final map = json as Map<String, dynamic>;
|
||||||
final filePath = map['filePath'] as String?;
|
final filePath = map['filePath'] as String?;
|
||||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
|
||||||
skippedDownloads++;
|
skippedDownloads++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -383,10 +483,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
'(${scannedList.length} new/updated, $skippedCount unchanged, '
|
'(${scannedList.length} new/updated, $skippedCount unchanged, '
|
||||||
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
|
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
|
||||||
);
|
);
|
||||||
|
await _showScanCompleteNotification(
|
||||||
|
totalTracks: items.length,
|
||||||
|
excludedDownloadedCount: skippedDownloads,
|
||||||
|
errorCount: state.scanErrorCount,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_log.e('Library scan failed: $e', e, stack);
|
_log.e('Library scan failed: $e', e, stack);
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: false);
|
state = state.copyWith(isScanning: false, scanWasCancelled: false);
|
||||||
|
await _showScanFailedNotification(e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
}
|
}
|
||||||
@@ -395,16 +501,43 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
void _startProgressPolling() {
|
void _startProgressPolling() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
||||||
|
if (_isProgressPollingInFlight) return;
|
||||||
|
_isProgressPollingInFlight = true;
|
||||||
try {
|
try {
|
||||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||||
|
final nextProgress =
|
||||||
state = state.copyWith(
|
(progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||||
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
|
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||||
scanCurrentFile: progress['current_file'] as String?,
|
0.0,
|
||||||
scanTotalFiles: progress['total_files'] as int? ?? 0,
|
100.0,
|
||||||
scannedFiles: progress['scanned_files'] as int? ?? 0,
|
|
||||||
scanErrorCount: progress['error_count'] as int? ?? 0,
|
|
||||||
);
|
);
|
||||||
|
final currentFile = progress['current_file'] as String?;
|
||||||
|
final totalFiles = progress['total_files'] as int? ?? 0;
|
||||||
|
final scannedFiles = progress['scanned_files'] as int? ?? 0;
|
||||||
|
final errorCount = progress['error_count'] as int? ?? 0;
|
||||||
|
|
||||||
|
final shouldUpdateState =
|
||||||
|
state.scanProgress != normalizedProgress ||
|
||||||
|
state.scanCurrentFile != currentFile ||
|
||||||
|
state.scanTotalFiles != totalFiles ||
|
||||||
|
state.scannedFiles != scannedFiles ||
|
||||||
|
state.scanErrorCount != errorCount;
|
||||||
|
|
||||||
|
if (shouldUpdateState) {
|
||||||
|
state = state.copyWith(
|
||||||
|
scanProgress: normalizedProgress,
|
||||||
|
scanCurrentFile: currentFile,
|
||||||
|
scanTotalFiles: totalFiles,
|
||||||
|
scannedFiles: scannedFiles,
|
||||||
|
scanErrorCount: errorCount,
|
||||||
|
);
|
||||||
|
await _showScanProgressNotification(
|
||||||
|
progress: normalizedProgress,
|
||||||
|
scannedFiles: scannedFiles,
|
||||||
|
totalFiles: totalFiles,
|
||||||
|
currentFile: currentFile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (progress['is_complete'] == true) {
|
if (progress['is_complete'] == true) {
|
||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
@@ -415,6 +548,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
if (_progressPollingErrorCount <= 3) {
|
if (_progressPollingErrorCount <= 3) {
|
||||||
_log.w('Library scan progress polling failed: $e');
|
_log.w('Library scan progress polling failed: $e');
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
_isProgressPollingInFlight = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -423,6 +558,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = null;
|
_progressTimer = null;
|
||||||
_progressPollingErrorCount = 0;
|
_progressPollingErrorCount = 0;
|
||||||
|
_isProgressPollingInFlight = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelScan() async {
|
Future<void> cancelScan() async {
|
||||||
@@ -433,6 +569,75 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
await PlatformBridge.cancelLibraryScan();
|
await PlatformBridge.cancelLibraryScan();
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
|
await _showScanCancelledNotification();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showScanProgressNotification({
|
||||||
|
required double progress,
|
||||||
|
required int scannedFiles,
|
||||||
|
required int totalFiles,
|
||||||
|
required String? currentFile,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _notificationService.showLibraryScanProgress(
|
||||||
|
progress: progress,
|
||||||
|
scannedFiles: scannedFiles,
|
||||||
|
totalFiles: totalFiles,
|
||||||
|
currentFile: _shortenFileForNotification(currentFile),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to show scan progress notification: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showScanCompleteNotification({
|
||||||
|
required int totalTracks,
|
||||||
|
required int excludedDownloadedCount,
|
||||||
|
required int errorCount,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _notificationService.showLibraryScanComplete(
|
||||||
|
totalTracks: totalTracks,
|
||||||
|
excludedDownloadedCount: excludedDownloadedCount,
|
||||||
|
errorCount: errorCount,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to show scan complete notification: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showScanFailedNotification(String message) async {
|
||||||
|
try {
|
||||||
|
await _notificationService.showLibraryScanFailed(message);
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to show scan failure notification: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showScanCancelledNotification() async {
|
||||||
|
try {
|
||||||
|
await _notificationService.showLibraryScanCancelled();
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to show scan cancelled notification: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _shortenFileForNotification(String? path) {
|
||||||
|
final raw = path?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) return null;
|
||||||
|
|
||||||
|
var decoded = raw;
|
||||||
|
try {
|
||||||
|
decoded = Uri.decodeFull(raw);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final slashIdx = decoded.lastIndexOf('/');
|
||||||
|
final backslashIdx = decoded.lastIndexOf('\\');
|
||||||
|
final cut = slashIdx > backslashIdx ? slashIdx : backslashIdx;
|
||||||
|
if (cut >= 0 && cut < decoded.length - 1) {
|
||||||
|
return decoded.substring(cut + 1);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> cleanupMissingFiles() async {
|
Future<int> cleanupMissingFiles() async {
|
||||||
@@ -560,17 +765,34 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final paths = legacyPaths
|
||||||
|
.where((path) => !path.startsWith('content://'))
|
||||||
|
.toList(growable: false);
|
||||||
|
const chunkSize = 24;
|
||||||
final backfilled = <String, int>{};
|
final backfilled = <String, int>{};
|
||||||
for (final path in legacyPaths) {
|
|
||||||
if (_scanCancelRequested || path.startsWith('content://')) {
|
for (var i = 0; i < paths.length; i += chunkSize) {
|
||||||
continue;
|
if (_scanCancelRequested) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
try {
|
final end = (i + chunkSize < paths.length) ? i + chunkSize : paths.length;
|
||||||
final stat = await File(path).stat();
|
final chunk = paths.sublist(i, end);
|
||||||
if (stat.type == FileSystemEntityType.file) {
|
final chunkEntries = await Future.wait<MapEntry<String, int>?>(
|
||||||
backfilled[path] = stat.modified.millisecondsSinceEpoch;
|
chunk.map((path) async {
|
||||||
|
try {
|
||||||
|
final stat = await File(path).stat();
|
||||||
|
if (stat.type == FileSystemEntityType.file) {
|
||||||
|
return MapEntry(path, stat.modified.millisecondsSinceEpoch);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (final entry in chunkEntries) {
|
||||||
|
if (entry != null) {
|
||||||
|
backfilled[entry.key] = entry.value;
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
}
|
||||||
}
|
}
|
||||||
return backfilled;
|
return backfilled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,6 +231,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setUsePrimaryArtistOnly(bool enabled) {
|
||||||
|
state = state.copyWith(usePrimaryArtistOnly: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFilterContributingArtistsInAlbumArtist(bool enabled) {
|
||||||
|
state = state.copyWith(filterContributingArtistsInAlbumArtist: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setHistoryViewMode(String mode) {
|
void setHistoryViewMode(String mode) {
|
||||||
state = state.copyWith(historyViewMode: mode);
|
state = state.copyWith(historyViewMode: mode);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ class TrackState {
|
|||||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
||||||
final bool hasSearchText; // For back button handling
|
final bool hasSearchText; // For back button handling
|
||||||
final bool isShowingRecentAccess; // For recent access mode
|
final bool isShowingRecentAccess; // For recent access mode
|
||||||
final String? searchExtensionId; // Extension ID used for current search results
|
final String?
|
||||||
final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
searchExtensionId; // Extension ID used for current search results
|
||||||
|
final String?
|
||||||
|
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
@@ -52,7 +54,12 @@ class TrackState {
|
|||||||
this.selectedSearchFilter,
|
this.selectedSearchFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty) || (searchAlbums != null && searchAlbums!.isNotEmpty) || (searchPlaylists != null && searchPlaylists!.isNotEmpty);
|
bool get hasContent =>
|
||||||
|
tracks.isNotEmpty ||
|
||||||
|
artistAlbums != null ||
|
||||||
|
(searchArtists != null && searchArtists!.isNotEmpty) ||
|
||||||
|
(searchAlbums != null && searchAlbums!.isNotEmpty) ||
|
||||||
|
(searchPlaylists != null && searchPlaylists!.isNotEmpty);
|
||||||
|
|
||||||
TrackState copyWith({
|
TrackState copyWith({
|
||||||
List<Track>? tracks,
|
List<Track>? tracks,
|
||||||
@@ -95,9 +102,12 @@ class TrackState {
|
|||||||
searchAlbums: searchAlbums ?? this.searchAlbums,
|
searchAlbums: searchAlbums ?? this.searchAlbums,
|
||||||
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
|
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
|
||||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
isShowingRecentAccess:
|
||||||
|
isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||||
searchExtensionId: searchExtensionId,
|
searchExtensionId: searchExtensionId,
|
||||||
selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter),
|
selectedSearchFilter: clearSelectedSearchFilter
|
||||||
|
? null
|
||||||
|
: (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,6 +188,7 @@ class SearchPlaylist {
|
|||||||
|
|
||||||
class TrackNotifier extends Notifier<TrackState> {
|
class TrackNotifier extends Notifier<TrackState> {
|
||||||
int _currentRequestId = 0;
|
int _currentRequestId = 0;
|
||||||
|
static const int _maxPreWarmTracksPerRequest = 80;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TrackState build() {
|
TrackState build() {
|
||||||
@@ -205,13 +216,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
// Check if we got valid data
|
// Check if we got valid data
|
||||||
if (result != null && result['type'] == 'track' && result['track'] != null) {
|
if (result != null &&
|
||||||
|
result['type'] == 'track' &&
|
||||||
|
result['track'] != null) {
|
||||||
final trackData = result['track'] as Map<String, dynamic>;
|
final trackData = result['track'] as Map<String, dynamic>;
|
||||||
final name = trackData['name']?.toString() ?? '';
|
final name = trackData['name']?.toString() ?? '';
|
||||||
if (name.isNotEmpty) {
|
if (name.isNotEmpty) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if (result != null && (result['type'] == 'album' || result['type'] == 'playlist')) {
|
} else if (result != null &&
|
||||||
|
(result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||||
break;
|
break;
|
||||||
} else if (result != null && result['type'] == 'artist') {
|
} else if (result != null && result['type'] == 'artist') {
|
||||||
break;
|
break;
|
||||||
@@ -245,15 +259,27 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
searchExtensionId: extensionId,
|
searchExtensionId: extensionId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) {
|
} else if ((type == 'album' || type == 'playlist') &&
|
||||||
|
result['tracks'] != null) {
|
||||||
final trackList = result['tracks'] as List<dynamic>;
|
final trackList = result['tracks'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
final tracks = trackList
|
||||||
|
.map(
|
||||||
|
(t) => _parseSearchTrack(
|
||||||
|
t as Map<String, dynamic>,
|
||||||
|
source: extensionId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumId: result['album']?['id'] as String?,
|
albumId: result['album']?['id'] as String?,
|
||||||
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
|
albumName:
|
||||||
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
result['name'] as String? ??
|
||||||
|
result['album']?['name'] as String?,
|
||||||
|
playlistName: type == 'playlist'
|
||||||
|
? result['name'] as String?
|
||||||
|
: null,
|
||||||
coverUrl: result['cover_url'] as String?,
|
coverUrl: result['cover_url'] as String?,
|
||||||
searchExtensionId: extensionId,
|
searchExtensionId: extensionId,
|
||||||
);
|
);
|
||||||
@@ -261,17 +287,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'artist' && result['artist'] != null) {
|
} else if (type == 'artist' && result['artist'] != null) {
|
||||||
final artistData = result['artist'] as Map<String, dynamic>;
|
final artistData = result['artist'] as Map<String, dynamic>;
|
||||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
final albums = albumsList
|
||||||
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
final topTracksList =
|
||||||
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||||
|
final topTracks = topTracksList
|
||||||
|
.map(
|
||||||
|
(t) => _parseSearchTrack(
|
||||||
|
t as Map<String, dynamic>,
|
||||||
|
source: extensionId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [],
|
tracks: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
artistId: artistData['id'] as String?,
|
artistId: artistData['id'] as String?,
|
||||||
artistName: artistData['name'] as String?,
|
artistName: artistData['name'] as String?,
|
||||||
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
|
coverUrl:
|
||||||
|
artistData['image_url'] as String? ??
|
||||||
|
artistData['images'] as String?,
|
||||||
headerImageUrl: artistData['header_image'] as String?,
|
headerImageUrl: artistData['header_image'] as String?,
|
||||||
monthlyListeners: artistData['listeners'] as int?,
|
monthlyListeners: artistData['listeners'] as int?,
|
||||||
artistAlbums: albums,
|
artistAlbums: albums,
|
||||||
@@ -306,7 +344,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'album') {
|
} else if (type == 'album') {
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -316,9 +356,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
);
|
);
|
||||||
_preWarmCacheForTracks(tracks);
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
final playlistInfo =
|
||||||
|
metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -329,7 +372,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
final albums = albumsList
|
||||||
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [],
|
tracks: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -357,7 +402,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
try {
|
try {
|
||||||
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
|
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
|
||||||
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(url);
|
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(
|
||||||
|
url,
|
||||||
|
);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final spotifyUrl = conversion['spotify_url'] as String?;
|
final spotifyUrl = conversion['spotify_url'] as String?;
|
||||||
@@ -365,7 +412,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
|
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
|
||||||
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
|
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(spotifyUrl);
|
final metadata =
|
||||||
|
await PlatformBridge.getSpotifyMetadataWithFallback(
|
||||||
|
spotifyUrl,
|
||||||
|
);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
@@ -378,8 +428,13 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
return;
|
return;
|
||||||
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
|
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
|
||||||
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
|
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
|
||||||
final deezerParsed = await PlatformBridge.parseDeezerUrl(deezerUrl);
|
final deezerParsed = await PlatformBridge.parseDeezerUrl(
|
||||||
final metadata = await PlatformBridge.getDeezerMetadata('track', deezerParsed['id'] as String);
|
deezerUrl,
|
||||||
|
);
|
||||||
|
final metadata = await PlatformBridge.getDeezerMetadata(
|
||||||
|
'track',
|
||||||
|
deezerParsed['id'] as String,
|
||||||
|
);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
@@ -399,7 +454,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
// For album/artist/playlist, not yet supported
|
// For album/artist/playlist, not yet supported
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
|
error:
|
||||||
|
'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -432,7 +488,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'album') {
|
} else if (type == 'album') {
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -444,7 +502,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -456,7 +516,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
final albums = albumsList
|
||||||
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [],
|
tracks: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -468,17 +530,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
state = TrackState(
|
||||||
|
isLoading: false,
|
||||||
|
error: e.toString(),
|
||||||
|
hasSearchText: state.hasSearchText,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(String query, {String? metadataSource, String? filterOverride}) async {
|
Future<void> search(
|
||||||
|
String query, {
|
||||||
|
String? metadataSource,
|
||||||
|
String? filterOverride,
|
||||||
|
}) async {
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve selected filter during loading
|
// Preserve selected filter during loading
|
||||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||||
|
|
||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
state = TrackState(
|
||||||
|
isLoading: true,
|
||||||
|
hasSearchText: state.hasSearchText,
|
||||||
|
selectedSearchFilter: currentFilter,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
@@ -505,7 +579,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
if (useExtensions) {
|
if (useExtensions) {
|
||||||
try {
|
try {
|
||||||
_log.d('Calling extension search API...');
|
_log.d('Calling extension search API...');
|
||||||
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
final extResults = await PlatformBridge.searchTracksWithExtensions(
|
||||||
|
query,
|
||||||
|
limit: 20,
|
||||||
|
);
|
||||||
_log.i('Extensions returned ${extResults.length} tracks');
|
_log.i('Extensions returned ${extResults.length} tracks');
|
||||||
|
|
||||||
for (final t in extResults) {
|
for (final t in extResults) {
|
||||||
@@ -522,12 +599,25 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
if (source == 'deezer') {
|
if (source == 'deezer') {
|
||||||
_log.d('Calling Deezer search API...');
|
_log.d('Calling Deezer search API...');
|
||||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 2, filter: currentFilter);
|
results = await PlatformBridge.searchDeezerAll(
|
||||||
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums');
|
query,
|
||||||
|
trackLimit: 20,
|
||||||
|
artistLimit: 2,
|
||||||
|
filter: currentFilter,
|
||||||
|
);
|
||||||
|
_log.i(
|
||||||
|
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
_log.d('Calling Spotify search API...');
|
_log.d('Calling Spotify search API...');
|
||||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 2);
|
results = await PlatformBridge.searchSpotifyAll(
|
||||||
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
query,
|
||||||
|
trackLimit: 20,
|
||||||
|
artistLimit: 2,
|
||||||
|
);
|
||||||
|
_log.i(
|
||||||
|
'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) {
|
if (!_isRequestValid(requestId)) {
|
||||||
@@ -539,7 +629,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||||
final albumList = results['albums'] as List<dynamic>? ?? [];
|
final albumList = results['albums'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums');
|
_log.d(
|
||||||
|
'Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
|
||||||
|
);
|
||||||
|
|
||||||
final tracks = <Track>[];
|
final tracks = <Track>[];
|
||||||
|
|
||||||
@@ -610,7 +702,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully');
|
_log.i(
|
||||||
|
'Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully',
|
||||||
|
);
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -624,23 +718,37 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
_log.e('Search failed: $e', e, stackTrace);
|
_log.e('Search failed: $e', e, stackTrace);
|
||||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
state = TrackState(
|
||||||
|
isLoading: false,
|
||||||
|
error: e.toString(),
|
||||||
|
hasSearchText: state.hasSearchText,
|
||||||
|
selectedSearchFilter: currentFilter,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
Future<void> customSearch(
|
||||||
|
String extensionId,
|
||||||
|
String query, {
|
||||||
|
Map<String, dynamic>? options,
|
||||||
|
}) async {
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading
|
selectedSearchFilter:
|
||||||
|
state.selectedSearchFilter, // Preserve filter during loading
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_log.i('Custom search started: extension=$extensionId, query="$query"');
|
_log.i('Custom search started: extension=$extensionId, query="$query"');
|
||||||
|
|
||||||
final results = await PlatformBridge.customSearchWithExtension(extensionId, query, options: options);
|
final results = await PlatformBridge.customSearchWithExtension(
|
||||||
|
extensionId,
|
||||||
|
query,
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) {
|
if (!_isRequestValid(requestId)) {
|
||||||
_log.w('Custom search request cancelled (requestId=$requestId)');
|
_log.w('Custom search request cancelled (requestId=$requestId)');
|
||||||
@@ -659,7 +767,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)');
|
_log.i(
|
||||||
|
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
|
||||||
|
);
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -667,12 +777,17 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
searchExtensionId: extensionId, // Store which extension was used
|
searchExtensionId: extensionId, // Store which extension was used
|
||||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter
|
selectedSearchFilter:
|
||||||
|
state.selectedSearchFilter, // Preserve selected filter
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
_log.e('Custom search failed: $e', e, stackTrace);
|
_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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,7 +798,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
if (track.isrc == null || track.isrc!.isEmpty) return;
|
if (track.isrc == null || track.isrc!.isEmpty) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final availability = await PlatformBridge.checkAvailability(track.id, track.isrc!);
|
final availability = await PlatformBridge.checkAvailability(
|
||||||
|
track.id,
|
||||||
|
track.isrc!,
|
||||||
|
);
|
||||||
final updatedTrack = Track(
|
final updatedTrack = Track(
|
||||||
id: track.id,
|
id: track.id,
|
||||||
name: track.name,
|
name: track.name,
|
||||||
@@ -738,6 +856,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setShowingRecentAccess(bool showing) {
|
void setShowingRecentAccess(bool showing) {
|
||||||
|
if (state.isShowingRecentAccess == showing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
state = state.copyWith(isShowingRecentAccess: showing);
|
state = state.copyWith(isShowingRecentAccess: showing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +918,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
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(),
|
source:
|
||||||
|
source ??
|
||||||
|
data['source']?.toString() ??
|
||||||
|
data['provider_id']?.toString(),
|
||||||
albumType: data['album_type']?.toString(),
|
albumType: data['album_type']?.toString(),
|
||||||
itemType: itemType,
|
itemType: itemType,
|
||||||
);
|
);
|
||||||
@@ -849,16 +973,25 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
if (tracks.isEmpty) return;
|
||||||
if (tracksWithIsrc.isEmpty) return;
|
final cacheRequests = <Map<String, String>>[];
|
||||||
|
for (final track in tracks) {
|
||||||
final cacheRequests = tracksWithIsrc.map((t) => {
|
final isrc = track.isrc;
|
||||||
'isrc': t.isrc!,
|
if (isrc == null || isrc.isEmpty) {
|
||||||
'track_name': t.name,
|
continue;
|
||||||
'artist_name': t.artistName,
|
}
|
||||||
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
cacheRequests.add({
|
||||||
'service': 'tidal',
|
'isrc': isrc,
|
||||||
}).toList();
|
'track_name': track.name,
|
||||||
|
'artist_name': track.artistName,
|
||||||
|
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
|
||||||
|
'service': 'tidal',
|
||||||
|
});
|
||||||
|
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cacheRequests.isEmpty) return;
|
||||||
|
|
||||||
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
|
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,6 +268,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
(constraints.maxHeight - kToolbarHeight) /
|
(constraints.maxHeight - kToolbarHeight) /
|
||||||
(expandedHeight - kToolbarHeight);
|
(expandedHeight - kToolbarHeight);
|
||||||
final showContent = collapseRatio > 0.3;
|
final showContent = collapseRatio > 0.3;
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
|
||||||
|
.round()
|
||||||
|
.clamp(720, 1440)
|
||||||
|
.toInt();
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
collapseMode: CollapseMode.none,
|
collapseMode: CollapseMode.none,
|
||||||
@@ -279,6 +286,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: widget.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: backgroundMemCacheWidth,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
placeholder: (_, _) =>
|
placeholder: (_, _) =>
|
||||||
Container(color: colorScheme.surface),
|
Container(color: colorScheme.surface),
|
||||||
|
|||||||
@@ -490,6 +490,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
0,
|
0,
|
||||||
(sum, a) => sum + a.totalTracks,
|
(sum, a) => sum + a.totalTracks,
|
||||||
);
|
);
|
||||||
|
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
||||||
|
final compactLayout =
|
||||||
|
MediaQuery.sizeOf(context).width < 430 || textScale > 1.15;
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -510,53 +513,145 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: compactLayout
|
||||||
children: [
|
? Column(
|
||||||
IconButton(
|
|
||||||
onPressed: _exitSelectionMode,
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
tooltip: context.l10n.dialogCancel,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
context.l10n.discographySelectedCount(selectedCount),
|
children: [
|
||||||
style: Theme.of(context).textTheme.titleMedium
|
IconButton(
|
||||||
?.copyWith(fontWeight: FontWeight.w600),
|
onPressed: _exitSelectionMode,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: context.l10n.dialogCancel,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.l10n.discographySelectedCount(
|
||||||
|
selectedCount,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
if (selectedCount > 0)
|
||||||
|
Text(
|
||||||
|
context.l10n.tracksCount(totalTracks),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (selectedCount > 0)
|
const SizedBox(height: 8),
|
||||||
Text(
|
Row(
|
||||||
context.l10n.tracksCount(totalTracks),
|
children: [
|
||||||
style: Theme.of(context).textTheme.bodySmall
|
Expanded(
|
||||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
child: OutlinedButton(
|
||||||
|
onPressed: allSelected
|
||||||
|
? _deselectAll
|
||||||
|
: () => _selectAll(allAlbums),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
allSelected
|
||||||
|
? context.l10n.actionDeselect
|
||||||
|
: context.l10n.actionSelectAll,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: selectedCount > 0
|
||||||
|
? () => _downloadSelectedAlbums(
|
||||||
|
context,
|
||||||
|
selectedAlbums,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
context.l10n.discographyDownloadSelected,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: _exitSelectionMode,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: context.l10n.dialogCancel,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.l10n.discographySelectedCount(
|
||||||
|
selectedCount,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
if (selectedCount > 0)
|
||||||
|
Text(
|
||||||
|
context.l10n.tracksCount(totalTracks),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: allSelected
|
||||||
|
? _deselectAll
|
||||||
|
: () => _selectAll(allAlbums),
|
||||||
|
child: Text(
|
||||||
|
allSelected
|
||||||
|
? context.l10n.actionDeselect
|
||||||
|
: context.l10n.actionSelectAll,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: selectedCount > 0
|
||||||
|
? () => _downloadSelectedAlbums(
|
||||||
|
context,
|
||||||
|
selectedAlbums,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.download, size: 18),
|
||||||
|
label: Text(context.l10n.discographyDownloadSelected),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: allSelected
|
|
||||||
? _deselectAll
|
|
||||||
: () => _selectAll(allAlbums),
|
|
||||||
child: Text(
|
|
||||||
allSelected
|
|
||||||
? context.l10n.actionDeselect
|
|
||||||
: context.l10n.actionSelectAll,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: selectedCount > 0
|
|
||||||
? () => _downloadSelectedAlbums(context, selectedAlbums)
|
|
||||||
: null,
|
|
||||||
icon: const Icon(Icons.download, size: 18),
|
|
||||||
label: Text(context.l10n.discographyDownloadSelected),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1427,15 +1522,31 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
void _downloadTrack(Track track) {
|
void _downloadTrack(Track track) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
ref
|
|
||||||
.read(downloadQueueProvider.notifier)
|
void enqueue(String service, {String? quality}) {
|
||||||
.addToQueue(track, settings.defaultService);
|
ref
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
.read(downloadQueueProvider.notifier)
|
||||||
SnackBar(
|
.addToQueue(track, service, qualityOverride: quality);
|
||||||
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
duration: const Duration(seconds: 2),
|
SnackBar(
|
||||||
),
|
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
||||||
);
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.askQualityBeforeDownload) {
|
||||||
|
DownloadServicePicker.show(
|
||||||
|
context,
|
||||||
|
onSelect: (quality, service) {
|
||||||
|
if (!mounted) return;
|
||||||
|
enqueue(service, quality: quality);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(settings.defaultService);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAlbumSection(
|
Widget _buildAlbumSection(
|
||||||
@@ -1468,7 +1579,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
final album = albums[index];
|
final album = albums[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(album.id),
|
key: ValueKey(album.id),
|
||||||
child: _buildAlbumCard(album, colorScheme, tileSize: tileSize, sectionHeight: sectionHeight),
|
child: _buildAlbumCard(
|
||||||
|
album,
|
||||||
|
colorScheme,
|
||||||
|
tileSize: tileSize,
|
||||||
|
sectionHeight: sectionHeight,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -1601,9 +1717,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
album.name,
|
album.name,
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
context,
|
fontWeight: FontWeight.w500,
|
||||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -8,6 +9,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||||
|
|
||||||
/// Screen to display downloaded tracks from a specific album
|
/// Screen to display downloaded tracks from a specific album
|
||||||
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||||
@@ -32,6 +34,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
bool _embeddedCoverRefreshScheduled = false;
|
||||||
|
List<DownloadHistoryItem>? _albumTracksSourceCache;
|
||||||
|
List<DownloadHistoryItem>? _albumTracksCache;
|
||||||
|
List<DownloadHistoryItem>? _discGroupingSourceCache;
|
||||||
|
Map<int, List<DownloadHistoryItem>>? _discGroupingCache;
|
||||||
|
List<int>? _sortedDiscNumbersCache;
|
||||||
|
List<DownloadHistoryItem>? _commonQualitySourceCache;
|
||||||
|
String? _commonQualityCache;
|
||||||
|
List<DownloadHistoryItem>? _embeddedCoverSourceCache;
|
||||||
|
String? _embeddedCoverPathCache;
|
||||||
|
bool _embeddedCoverPathResolved = false;
|
||||||
|
|
||||||
|
String get _albumLookupKey =>
|
||||||
|
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -46,6 +62,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant DownloadedAlbumScreen oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.albumName != widget.albumName ||
|
||||||
|
oldWidget.artistName != widget.artistName) {
|
||||||
|
_albumTracksSourceCache = null;
|
||||||
|
_albumTracksCache = null;
|
||||||
|
_invalidateDerivedTrackCaches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
final shouldShow = _scrollController.offset > 280;
|
final shouldShow = _scrollController.offset > 280;
|
||||||
if (shouldShow != _showTitleInAppBar) {
|
if (shouldShow != _showTitleInAppBar) {
|
||||||
@@ -57,41 +84,74 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
List<DownloadHistoryItem> _getAlbumTracks(
|
List<DownloadHistoryItem> _getAlbumTracks(
|
||||||
List<DownloadHistoryItem> allItems,
|
List<DownloadHistoryItem> allItems,
|
||||||
) {
|
) {
|
||||||
return allItems.where((item) {
|
final cached = _albumTracksCache;
|
||||||
// Use albumArtist if available and not empty, otherwise artistName
|
if (cached != null && identical(allItems, _albumTracksSourceCache)) {
|
||||||
final itemArtist =
|
return cached;
|
||||||
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
}
|
||||||
? item.albumArtist!
|
|
||||||
: item.artistName;
|
final tracks =
|
||||||
// Use lowercase for case-insensitive matching
|
allItems.where((item) {
|
||||||
final itemKey =
|
// Use albumArtist if available and not empty, otherwise artistName
|
||||||
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
final itemArtist =
|
||||||
final albumKey =
|
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||||
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
? item.albumArtist!
|
||||||
return itemKey == albumKey;
|
: item.artistName;
|
||||||
}).toList()..sort((a, b) {
|
// Use lowercase for case-insensitive matching
|
||||||
// Sort by disc number first, then by track number
|
final itemKey =
|
||||||
final aDisc = a.discNumber ?? 1;
|
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||||
final bDisc = b.discNumber ?? 1;
|
return itemKey == _albumLookupKey;
|
||||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
}).toList()..sort((a, b) {
|
||||||
final aNum = a.trackNumber ?? 999;
|
// Sort by disc number first, then by track number
|
||||||
final bNum = b.trackNumber ?? 999;
|
final aDisc = a.discNumber ?? 1;
|
||||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
final bDisc = b.discNumber ?? 1;
|
||||||
return a.trackName.compareTo(b.trackName);
|
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||||
});
|
final aNum = a.trackNumber ?? 999;
|
||||||
|
final bNum = b.trackNumber ?? 999;
|
||||||
|
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||||
|
return a.trackName.compareTo(b.trackName);
|
||||||
|
});
|
||||||
|
|
||||||
|
_albumTracksSourceCache = allItems;
|
||||||
|
_albumTracksCache = tracks;
|
||||||
|
_invalidateDerivedTrackCaches();
|
||||||
|
return tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
|
void _invalidateDerivedTrackCaches() {
|
||||||
|
_discGroupingSourceCache = null;
|
||||||
|
_discGroupingCache = null;
|
||||||
|
_sortedDiscNumbersCache = null;
|
||||||
|
_commonQualitySourceCache = null;
|
||||||
|
_commonQualityCache = null;
|
||||||
|
_embeddedCoverSourceCache = null;
|
||||||
|
_embeddedCoverPathCache = null;
|
||||||
|
_embeddedCoverPathResolved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<int, List<DownloadHistoryItem>> _getDiscGroups(
|
||||||
List<DownloadHistoryItem> tracks,
|
List<DownloadHistoryItem> tracks,
|
||||||
) {
|
) {
|
||||||
|
final cached = _discGroupingCache;
|
||||||
|
if (cached != null && identical(tracks, _discGroupingSourceCache)) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
final discMap = <int, List<DownloadHistoryItem>>{};
|
final discMap = <int, List<DownloadHistoryItem>>{};
|
||||||
for (final track in tracks) {
|
for (final track in tracks) {
|
||||||
final discNumber = track.discNumber ?? 1;
|
final discNumber = track.discNumber ?? 1;
|
||||||
discMap.putIfAbsent(discNumber, () => []).add(track);
|
discMap.putIfAbsent(discNumber, () => []).add(track);
|
||||||
}
|
}
|
||||||
|
_discGroupingSourceCache = tracks;
|
||||||
|
_discGroupingCache = discMap;
|
||||||
|
_sortedDiscNumbersCache = discMap.keys.toList()..sort();
|
||||||
return discMap;
|
return discMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<int> _getSortedDiscNumbers(List<DownloadHistoryItem> tracks) {
|
||||||
|
_getDiscGroups(tracks);
|
||||||
|
return _sortedDiscNumbersCache ?? const [];
|
||||||
|
}
|
||||||
|
|
||||||
void _enterSelectionMode(String itemId) {
|
void _enterSelectionMode(String itemId) {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -152,10 +212,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||||
final idsToDelete = _selectedIds.toList();
|
final idsToDelete = _selectedIds.toList();
|
||||||
|
final tracksById = {for (final track in currentTracks) track.id: track};
|
||||||
|
|
||||||
int deletedCount = 0;
|
int deletedCount = 0;
|
||||||
for (final id in idsToDelete) {
|
for (final id in idsToDelete) {
|
||||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
final item = tracksById[id];
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
try {
|
try {
|
||||||
await deleteFile(item.filePath);
|
await deleteFile(item.filePath);
|
||||||
@@ -191,10 +252,28 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
void _onEmbeddedCoverChanged() {
|
||||||
|
if (!mounted || _embeddedCoverRefreshScheduled) return;
|
||||||
|
_embeddedCoverRefreshScheduled = true;
|
||||||
|
_embeddedCoverPathResolved = false;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_embeddedCoverRefreshScheduled = false;
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
_precacheCover(item.coverUrl);
|
_precacheCover(item.coverUrl);
|
||||||
Navigator.push(
|
final beforeModTime =
|
||||||
context,
|
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
|
||||||
|
item.filePath,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final result = await navigator.push(
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
@@ -204,6 +283,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||||
|
item.filePath,
|
||||||
|
beforeModTime: beforeModTime,
|
||||||
|
force: result == true,
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _precacheCover(String? url) {
|
void _precacheCover(String? url) {
|
||||||
@@ -211,8 +296,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||||
precacheImage(
|
precacheImage(
|
||||||
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
|
ResizeImage(
|
||||||
|
CachedNetworkImageProvider(
|
||||||
|
url,
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
),
|
||||||
|
width: targetSize,
|
||||||
|
height: targetSize,
|
||||||
|
),
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -256,7 +352,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
CustomScrollView(
|
CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
_buildAppBar(context, colorScheme),
|
_buildAppBar(context, colorScheme, tracks),
|
||||||
_buildInfoCard(context, colorScheme, tracks),
|
_buildInfoCard(context, colorScheme, tracks),
|
||||||
_buildTrackListHeader(context, colorScheme, tracks),
|
_buildTrackListHeader(context, colorScheme, tracks),
|
||||||
_buildTrackList(context, colorScheme, tracks),
|
_buildTrackList(context, colorScheme, tracks),
|
||||||
@@ -285,7 +381,32 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
String? _resolveAlbumEmbeddedCoverPath(List<DownloadHistoryItem> tracks) {
|
||||||
|
if (_embeddedCoverPathResolved &&
|
||||||
|
identical(tracks, _embeddedCoverSourceCache)) {
|
||||||
|
return _embeddedCoverPathCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
_embeddedCoverSourceCache = tracks;
|
||||||
|
_embeddedCoverPathResolved = true;
|
||||||
|
|
||||||
|
if (tracks.isEmpty) {
|
||||||
|
_embeddedCoverPathCache = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_embeddedCoverPathCache = DownloadedEmbeddedCoverResolver.resolve(
|
||||||
|
tracks.first.filePath,
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
);
|
||||||
|
return _embeddedCoverPathCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
List<DownloadHistoryItem> tracks,
|
||||||
|
) {
|
||||||
final mediaSize = MediaQuery.of(context).size;
|
final mediaSize = MediaQuery.of(context).size;
|
||||||
final screenWidth = mediaSize.width;
|
final screenWidth = mediaSize.width;
|
||||||
final shortestSide = mediaSize.shortestSide;
|
final shortestSide = mediaSize.shortestSide;
|
||||||
@@ -294,6 +415,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||||
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||||
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||||
|
final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks);
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: expandedHeight,
|
expandedHeight: expandedHeight,
|
||||||
@@ -322,6 +444,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
(constraints.maxHeight - kToolbarHeight) /
|
(constraints.maxHeight - kToolbarHeight) /
|
||||||
(expandedHeight - kToolbarHeight);
|
(expandedHeight - kToolbarHeight);
|
||||||
final showContent = collapseRatio > 0.3;
|
final showContent = collapseRatio > 0.3;
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
|
||||||
|
.round()
|
||||||
|
.clamp(720, 1440)
|
||||||
|
.toInt();
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
collapseMode: CollapseMode.none,
|
collapseMode: CollapseMode.none,
|
||||||
@@ -329,10 +458,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Blurred cover background
|
// Blurred cover background
|
||||||
if (widget.coverUrl != null)
|
if (embeddedCoverPath != null)
|
||||||
|
Image.file(
|
||||||
|
File(embeddedCoverPath),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
cacheWidth: backgroundMemCacheWidth,
|
||||||
|
errorBuilder: (_, _, _) =>
|
||||||
|
Container(color: colorScheme.surface),
|
||||||
|
)
|
||||||
|
else if (widget.coverUrl != null)
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: widget.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: backgroundMemCacheWidth,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
placeholder: (_, _) =>
|
placeholder: (_, _) =>
|
||||||
Container(color: colorScheme.surface),
|
Container(color: colorScheme.surface),
|
||||||
@@ -389,7 +527,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: widget.coverUrl != null
|
child: embeddedCoverPath != null
|
||||||
|
? Image.file(
|
||||||
|
File(embeddedCoverPath),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
cacheWidth: (coverSize * 2).toInt(),
|
||||||
|
cacheHeight: (coverSize * 2).toInt(),
|
||||||
|
errorBuilder: (_, _, _) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: fallbackIconSize,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: widget.coverUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: widget.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
@@ -437,6 +590,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
List<DownloadHistoryItem> tracks,
|
List<DownloadHistoryItem> tracks,
|
||||||
) {
|
) {
|
||||||
|
final commonQuality = _getCommonQuality(tracks);
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -500,22 +655,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (_getCommonQuality(tracks) != null)
|
if (commonQuality != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12,
|
horizontal: 12,
|
||||||
vertical: 6,
|
vertical: 6,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
color: commonQuality.startsWith('24')
|
||||||
? colorScheme.tertiaryContainer
|
? colorScheme.tertiaryContainer
|
||||||
: colorScheme.surfaceContainerHighest,
|
: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_getCommonQuality(tracks)!,
|
commonQuality,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
color: commonQuality.startsWith('24')
|
||||||
? colorScheme.onTertiaryContainer
|
? colorScheme.onTertiaryContainer
|
||||||
: colorScheme.onSurfaceVariant,
|
: colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -534,12 +689,30 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
|
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
|
||||||
if (tracks.isEmpty) return null;
|
if (identical(tracks, _commonQualitySourceCache)) {
|
||||||
final firstQuality = tracks.first.quality;
|
return _commonQualityCache;
|
||||||
if (firstQuality == null) return null;
|
|
||||||
for (final track in tracks) {
|
|
||||||
if (track.quality != firstQuality) return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tracks.isEmpty) {
|
||||||
|
_commonQualitySourceCache = tracks;
|
||||||
|
_commonQualityCache = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final firstQuality = tracks.first.quality;
|
||||||
|
if (firstQuality == null) {
|
||||||
|
_commonQualitySourceCache = tracks;
|
||||||
|
_commonQualityCache = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final track in tracks) {
|
||||||
|
if (track.quality != firstQuality) {
|
||||||
|
_commonQualitySourceCache = tracks;
|
||||||
|
_commonQualityCache = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_commonQualitySourceCache = tracks;
|
||||||
|
_commonQualityCache = firstQuality;
|
||||||
return firstQuality;
|
return firstQuality;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +758,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
List<DownloadHistoryItem> tracks,
|
List<DownloadHistoryItem> tracks,
|
||||||
) {
|
) {
|
||||||
final discMap = _groupTracksByDisc(tracks);
|
final discMap = _getDiscGroups(tracks);
|
||||||
|
|
||||||
if (discMap.length <= 1) {
|
if (discMap.length <= 1) {
|
||||||
return SliverList(
|
return SliverList(
|
||||||
@@ -599,7 +772,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final discNumbers = discMap.keys.toList()..sort();
|
final discNumbers = _getSortedDiscNumbers(tracks);
|
||||||
final List<Widget> children = [];
|
final List<Widget> children = [];
|
||||||
|
|
||||||
for (final discNumber in discNumbers) {
|
for (final discNumber in discNumbers) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
|||||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||||
import 'package:spotiflac_android/services/csv_import_service.dart';
|
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
@@ -34,16 +35,25 @@ class HomeTab extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _RecentAccessView {
|
class _RecentAccessView {
|
||||||
final List<RecentAccessItem> uniqueItems;
|
final List<RecentAccessItem> uniqueItems;
|
||||||
final List<RecentAccessItem> downloadItems;
|
final List<String> downloadIds;
|
||||||
|
final Map<String, String> downloadFilePathByRecentKey;
|
||||||
final bool hasHiddenDownloads;
|
final bool hasHiddenDownloads;
|
||||||
|
|
||||||
const _RecentAccessView({
|
const _RecentAccessView({
|
||||||
required this.uniqueItems,
|
required this.uniqueItems,
|
||||||
required this.downloadItems,
|
required this.downloadIds,
|
||||||
|
required this.downloadFilePathByRecentKey,
|
||||||
required this.hasHiddenDownloads,
|
required this.hasHiddenDownloads,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RecentAlbumAggregate {
|
||||||
|
int count;
|
||||||
|
DownloadHistoryItem mostRecent;
|
||||||
|
|
||||||
|
_RecentAlbumAggregate({required this.count, required this.mostRecent});
|
||||||
|
}
|
||||||
|
|
||||||
class _CsvImportOptions {
|
class _CsvImportOptions {
|
||||||
final bool confirmed;
|
final bool confirmed;
|
||||||
final bool skipDownloaded;
|
final bool skipDownloaded;
|
||||||
@@ -57,7 +67,6 @@ class _CsvImportOptions {
|
|||||||
class _HomeTabState extends ConsumerState<HomeTab>
|
class _HomeTabState extends ConsumerState<HomeTab>
|
||||||
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
||||||
final _urlController = TextEditingController();
|
final _urlController = TextEditingController();
|
||||||
bool _isTyping = false;
|
|
||||||
final FocusNode _searchFocusNode = FocusNode();
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
String? _lastSearchQuery;
|
String? _lastSearchQuery;
|
||||||
late final ProviderSubscription<TrackState> _trackStateSub;
|
late final ProviderSubscription<TrackState> _trackStateSub;
|
||||||
@@ -74,6 +83,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
List<RecentAccessItem>? _recentAccessItemsCache;
|
List<RecentAccessItem>? _recentAccessItemsCache;
|
||||||
Set<String>? _recentAccessHiddenIdsCache;
|
Set<String>? _recentAccessHiddenIdsCache;
|
||||||
_RecentAccessView? _recentAccessViewCache;
|
_RecentAccessView? _recentAccessViewCache;
|
||||||
|
bool _embeddedCoverRefreshScheduled = false;
|
||||||
|
List<Extension>? _thumbnailSizesExtensionsCache;
|
||||||
|
Map<String, (double, double)>? _thumbnailSizesCache;
|
||||||
|
|
||||||
double _responsiveScale({
|
double _responsiveScale({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
@@ -197,6 +209,27 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, (double, double)> _getThumbnailSizesByExtensionId(
|
||||||
|
List<Extension> extensions,
|
||||||
|
) {
|
||||||
|
final cached = _thumbnailSizesCache;
|
||||||
|
if (cached != null &&
|
||||||
|
identical(extensions, _thumbnailSizesExtensionsCache)) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
final map = <String, (double, double)>{
|
||||||
|
for (final extension in extensions)
|
||||||
|
if (extension.searchBehavior != null)
|
||||||
|
extension.id: extension.searchBehavior!.getThumbnailSize(
|
||||||
|
defaultSize: 56,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
_thumbnailSizesExtensionsCache = extensions;
|
||||||
|
_thumbnailSizesCache = map;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
void _onSearchFocusChanged() {
|
void _onSearchFocusChanged() {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
@@ -214,7 +247,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
_urlController.text.isNotEmpty &&
|
_urlController.text.isNotEmpty &&
|
||||||
!_searchFocusNode.hasFocus) {
|
!_searchFocusNode.hasFocus) {
|
||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
setState(() => _isTyping = false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,10 +269,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
|
|
||||||
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
||||||
|
|
||||||
if (text.isNotEmpty && !_isTyping) {
|
if (text.isEmpty) {
|
||||||
setState(() => _isTyping = true);
|
|
||||||
} else if (text.isEmpty && _isTyping) {
|
|
||||||
setState(() => _isTyping = false);
|
|
||||||
_liveSearchDebounce?.cancel();
|
_liveSearchDebounce?.cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -347,7 +376,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
_lastSearchQuery = null;
|
_lastSearchQuery = null;
|
||||||
setState(() => _isTyping = false);
|
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +415,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
setState(() => _isTyping = false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +440,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
setState(() => _isTyping = false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +461,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
setState(() => _isTyping = false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -778,13 +803,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
final showLocalLibraryIndicator =
|
final showLocalLibraryIndicator =
|
||||||
localLibrarySettings.$1 && localLibrarySettings.$2;
|
localLibrarySettings.$1 && localLibrarySettings.$2;
|
||||||
final thumbnailSizesByExtensionId = <String, (double, double)>{
|
final thumbnailSizesByExtensionId = _getThumbnailSizesByExtensionId(
|
||||||
for (final extension in extensions)
|
extensions,
|
||||||
if (extension.searchBehavior != null)
|
);
|
||||||
extension.id: extension.searchBehavior!.getThumbnailSize(
|
|
||||||
defaultSize: 56,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
Extension? currentSearchExtension;
|
Extension? currentSearchExtension;
|
||||||
List<SearchFilter> searchFilters = [];
|
List<SearchFilter> searchFilters = [];
|
||||||
|
|
||||||
@@ -932,7 +953,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Search filter bar (only shown when has search results)
|
// Search filter bar (only shown when has search results)
|
||||||
if (searchFilters.isNotEmpty && hasActualResults && !showRecentAccess)
|
if (searchFilters.isNotEmpty &&
|
||||||
|
hasActualResults &&
|
||||||
|
!showRecentAccess)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _buildSearchFilterBar(
|
child: _buildSearchFilterBar(
|
||||||
searchFilters,
|
searchFilters,
|
||||||
@@ -1022,6 +1045,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onEmbeddedCoverChanged() {
|
||||||
|
if (!mounted || _embeddedCoverRefreshScheduled) return;
|
||||||
|
_embeddedCoverRefreshScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_embeddedCoverRefreshScheduled = false;
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildRecentDownloads(
|
Widget _buildRecentDownloads(
|
||||||
List<DownloadHistoryItem> items,
|
List<DownloadHistoryItem> items,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
@@ -1049,6 +1083,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
itemCount: itemCount,
|
itemCount: itemCount,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
|
final embeddedCoverPath = DownloadedEmbeddedCoverResolver.resolve(
|
||||||
|
item.filePath,
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
);
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(item.id),
|
key: ValueKey(item.id),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -1060,7 +1098,26 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: item.coverUrl != null
|
child: embeddedCoverPath != null
|
||||||
|
? Image.file(
|
||||||
|
File(embeddedCoverPath),
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
cacheWidth: (coverSize * 2).round(),
|
||||||
|
cacheHeight: (coverSize * 2).round(),
|
||||||
|
errorBuilder: (_, _, _) => Container(
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: item.coverUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: item.coverUrl!,
|
imageUrl: item.coverUrl!,
|
||||||
width: coverSize,
|
width: coverSize,
|
||||||
@@ -1115,63 +1172,58 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
final albumGroups = <String, List<DownloadHistoryItem>>{};
|
final albumGroups = <String, _RecentAlbumAggregate>{};
|
||||||
for (final h in historyItems) {
|
for (final h in historyItems) {
|
||||||
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
|
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
|
||||||
? h.albumArtist!
|
? h.albumArtist!
|
||||||
: h.artistName;
|
: h.artistName;
|
||||||
final albumKey = '${h.albumName}|$artistForKey';
|
final albumKey = '${h.albumName}|$artistForKey';
|
||||||
albumGroups.putIfAbsent(albumKey, () => []).add(h);
|
final existing = albumGroups[albumKey];
|
||||||
|
if (existing == null) {
|
||||||
|
albumGroups[albumKey] = _RecentAlbumAggregate(count: 1, mostRecent: h);
|
||||||
|
} else {
|
||||||
|
existing.count++;
|
||||||
|
if (h.downloadedAt.isAfter(existing.mostRecent.downloadedAt)) {
|
||||||
|
existing.mostRecent = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final downloadItems = <RecentAccessItem>[];
|
final downloadIds = <String>[];
|
||||||
for (final entry in albumGroups.entries) {
|
final visibleDownloads = <RecentAccessItem>[];
|
||||||
final tracks = entry.value;
|
final downloadFilePathByRecentKey = <String, String>{};
|
||||||
final mostRecent = tracks.reduce(
|
for (final aggregate in albumGroups.values) {
|
||||||
(a, b) => a.downloadedAt.isAfter(b.downloadedAt) ? a : b,
|
final mostRecent = aggregate.mostRecent;
|
||||||
);
|
|
||||||
final artistForKey =
|
final artistForKey =
|
||||||
(mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty)
|
(mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty)
|
||||||
? mostRecent.albumArtist!
|
? mostRecent.albumArtist!
|
||||||
: mostRecent.artistName;
|
: mostRecent.artistName;
|
||||||
|
|
||||||
if (tracks.length == 1) {
|
final isSingleTrack = aggregate.count == 1;
|
||||||
downloadItems.add(
|
final recentId = isSingleTrack
|
||||||
RecentAccessItem(
|
? (mostRecent.spotifyId ?? mostRecent.id)
|
||||||
id: mostRecent.spotifyId ?? mostRecent.id,
|
: '${mostRecent.albumName}|$artistForKey';
|
||||||
name: mostRecent.trackName,
|
final recent = RecentAccessItem(
|
||||||
subtitle: mostRecent.artistName,
|
id: recentId,
|
||||||
imageUrl: mostRecent.coverUrl,
|
name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName,
|
||||||
type: RecentAccessType.track,
|
subtitle: isSingleTrack ? mostRecent.artistName : artistForKey,
|
||||||
accessedAt: mostRecent.downloadedAt,
|
imageUrl: mostRecent.coverUrl,
|
||||||
providerId: 'download',
|
type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album,
|
||||||
),
|
accessedAt: mostRecent.downloadedAt,
|
||||||
);
|
providerId: 'download',
|
||||||
} else {
|
);
|
||||||
downloadItems.add(
|
|
||||||
RecentAccessItem(
|
downloadIds.add(recentId);
|
||||||
id: '${mostRecent.albumName}|$artistForKey',
|
downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] =
|
||||||
name: mostRecent.albumName,
|
mostRecent.filePath;
|
||||||
subtitle: artistForKey,
|
if (!hiddenIds.contains(recentId)) {
|
||||||
imageUrl: mostRecent.coverUrl,
|
visibleDownloads.add(recent);
|
||||||
type: RecentAccessType.album,
|
|
||||||
accessedAt: mostRecent.downloadedAt,
|
|
||||||
providerId: 'download',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
||||||
|
if (visibleDownloads.length > 10) {
|
||||||
final visibleDownloads = <RecentAccessItem>[];
|
visibleDownloads.removeRange(10, visibleDownloads.length);
|
||||||
for (final item in downloadItems) {
|
|
||||||
if (!hiddenIds.contains(item.id)) {
|
|
||||||
visibleDownloads.add(item);
|
|
||||||
if (visibleDownloads.length >= 10) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final allItems = <RecentAccessItem>[...items, ...visibleDownloads];
|
final allItems = <RecentAccessItem>[...items, ...visibleDownloads];
|
||||||
@@ -1191,7 +1243,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
|
|
||||||
final view = _RecentAccessView(
|
final view = _RecentAccessView(
|
||||||
uniqueItems: uniqueItems,
|
uniqueItems: uniqueItems,
|
||||||
downloadItems: downloadItems,
|
downloadIds: downloadIds,
|
||||||
|
downloadFilePathByRecentKey: downloadFilePathByRecentKey,
|
||||||
hasHiddenDownloads: hiddenIds.isNotEmpty,
|
hasHiddenDownloads: hiddenIds.isNotEmpty,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1604,7 +1657,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
|
|
||||||
Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) {
|
Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) {
|
||||||
final uniqueItems = view.uniqueItems;
|
final uniqueItems = view.uniqueItems;
|
||||||
final downloadItems = view.downloadItems;
|
final downloadIds = view.downloadIds;
|
||||||
final hasHiddenDownloads = view.hasHiddenDownloads;
|
final hasHiddenDownloads = view.hasHiddenDownloads;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -1624,10 +1677,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
if (uniqueItems.isNotEmpty)
|
if (uniqueItems.isNotEmpty)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
for (final item in downloadItems) {
|
for (final id in downloadIds) {
|
||||||
ref
|
ref
|
||||||
.read(recentAccessProvider.notifier)
|
.read(recentAccessProvider.notifier)
|
||||||
.hideDownloadFromRecents(item.id);
|
.hideDownloadFromRecents(id);
|
||||||
}
|
}
|
||||||
ref.read(recentAccessProvider.notifier).clearHistory();
|
ref.read(recentAccessProvider.notifier).clearHistory();
|
||||||
},
|
},
|
||||||
@@ -1680,7 +1733,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
...uniqueItems.map(
|
...uniqueItems.map(
|
||||||
(item) => _buildRecentAccessItem(item, colorScheme),
|
(item) => _buildRecentAccessItem(
|
||||||
|
item,
|
||||||
|
colorScheme,
|
||||||
|
view.downloadFilePathByRecentKey,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1690,10 +1747,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
Widget _buildRecentAccessItem(
|
Widget _buildRecentAccessItem(
|
||||||
RecentAccessItem item,
|
RecentAccessItem item,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
|
Map<String, String> downloadFilePathByRecentKey,
|
||||||
) {
|
) {
|
||||||
IconData typeIcon;
|
IconData typeIcon;
|
||||||
String typeLabel;
|
String typeLabel;
|
||||||
final isDownloaded = item.providerId == 'download';
|
final isDownloaded = item.providerId == 'download';
|
||||||
|
final embeddedCoverPath = isDownloaded
|
||||||
|
? DownloadedEmbeddedCoverResolver.resolve(
|
||||||
|
downloadFilePathByRecentKey['${item.type.name}:${item.id}'],
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case RecentAccessType.artist:
|
case RecentAccessType.artist:
|
||||||
@@ -1723,7 +1787,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
item.type == RecentAccessType.artist ? 28 : 4,
|
item.type == RecentAccessType.artist ? 28 : 4,
|
||||||
),
|
),
|
||||||
child: item.imageUrl != null && item.imageUrl!.isNotEmpty
|
child: embeddedCoverPath != null
|
||||||
|
? Image.file(
|
||||||
|
File(embeddedCoverPath),
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
cacheWidth: 112,
|
||||||
|
cacheHeight: 112,
|
||||||
|
errorBuilder: (context, error, stackTrace) => Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
typeIcon,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: item.imageUrl != null && item.imageUrl!.isNotEmpty
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: item.imageUrl!,
|
imageUrl: item.imageUrl!,
|
||||||
width: 56,
|
width: 56,
|
||||||
@@ -1896,10 +1978,15 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
_precacheCover(item.coverUrl);
|
_precacheCover(item.coverUrl);
|
||||||
Navigator.push(
|
final beforeModTime =
|
||||||
context,
|
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
|
||||||
|
item.filePath,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
final result = await navigator.push(
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
@@ -1909,6 +1996,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||||
|
item.filePath,
|
||||||
|
beforeModTime: beforeModTime,
|
||||||
|
force: result == true,
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _precacheCover(String? url) {
|
void _precacheCover(String? url) {
|
||||||
@@ -1916,8 +2009,19 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||||
precacheImage(
|
precacheImage(
|
||||||
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
|
ResizeImage(
|
||||||
|
CachedNetworkImageProvider(
|
||||||
|
url,
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
),
|
||||||
|
width: targetSize,
|
||||||
|
height: targetSize,
|
||||||
|
),
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
|
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
|
||||||
late List<int> _sortedDiscNumbersCache;
|
late List<int> _sortedDiscNumbersCache;
|
||||||
late bool _hasMultipleDiscsCache;
|
late bool _hasMultipleDiscsCache;
|
||||||
|
String? _commonQualityCache;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -87,6 +88,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
|
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
|
||||||
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
|
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
|
||||||
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
|
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
|
||||||
|
_commonQualityCache = _computeCommonQuality(_sortedTracksCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
|
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
|
||||||
@@ -160,15 +162,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final libraryNotifier = ref.read(localLibraryProvider.notifier);
|
final libraryNotifier = ref.read(localLibraryProvider.notifier);
|
||||||
final idsToDelete = _selectedIds.toList();
|
final idsToDelete = _selectedIds.toList();
|
||||||
|
final tracksById = {for (final track in currentTracks) track.id: track};
|
||||||
|
|
||||||
int deletedCount = 0;
|
int deletedCount = 0;
|
||||||
for (final id in idsToDelete) {
|
for (final id in idsToDelete) {
|
||||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
final item = tracksById[id];
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
try {
|
try {
|
||||||
await deleteFile(item.filePath);
|
await deleteFile(item.filePath);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
libraryNotifier.removeItem(id);
|
await libraryNotifier.removeItem(id);
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,6 +428,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
List<LocalLibraryItem> tracks,
|
List<LocalLibraryItem> tracks,
|
||||||
) {
|
) {
|
||||||
|
final commonQuality = _commonQualityCache;
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -519,22 +524,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// Quality badge if all tracks have the same quality
|
// Quality badge if all tracks have the same quality
|
||||||
if (_getCommonQuality(tracks) != null)
|
if (commonQuality != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12,
|
horizontal: 12,
|
||||||
vertical: 6,
|
vertical: 6,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getCommonQuality(tracks)!.contains('24')
|
color: commonQuality.contains('24')
|
||||||
? colorScheme.primaryContainer
|
? colorScheme.primaryContainer
|
||||||
: colorScheme.surfaceContainerHighest,
|
: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_getCommonQuality(tracks)!,
|
commonQuality,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _getCommonQuality(tracks)!.contains('24')
|
color: commonQuality.contains('24')
|
||||||
? colorScheme.onPrimaryContainer
|
? colorScheme.onPrimaryContainer
|
||||||
: colorScheme.onSurfaceVariant,
|
: colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -552,10 +557,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _getCommonQuality(List<LocalLibraryItem> tracks) {
|
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
|
||||||
if (tracks.isEmpty) return null;
|
if (tracks.isEmpty) return null;
|
||||||
final first = tracks.first;
|
final first = tracks.first;
|
||||||
if (first.bitDepth == null || first.sampleRate == null) return null;
|
|
||||||
|
// For lossy formats, use bitrate
|
||||||
|
if (first.bitrate != null && first.bitrate! > 0) {
|
||||||
|
final fmt = first.format?.toUpperCase() ?? '';
|
||||||
|
final firstBitrate = first.bitrate;
|
||||||
|
for (final track in tracks) {
|
||||||
|
if (track.bitrate != firstBitrate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '$fmt ${firstBitrate}kbps'.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For lossless formats, use bit depth / sample rate
|
||||||
|
if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) return null;
|
||||||
|
|
||||||
final firstQuality =
|
final firstQuality =
|
||||||
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
|
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
(constraints.maxHeight - kToolbarHeight) /
|
(constraints.maxHeight - kToolbarHeight) /
|
||||||
(expandedHeight - kToolbarHeight);
|
(expandedHeight - kToolbarHeight);
|
||||||
final showContent = collapseRatio > 0.3;
|
final showContent = collapseRatio > 0.3;
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
|
||||||
|
.round()
|
||||||
|
.clamp(720, 1440)
|
||||||
|
.toInt();
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
collapseMode: CollapseMode.none,
|
collapseMode: CollapseMode.none,
|
||||||
@@ -192,6 +199,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: widget.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: backgroundMemCacheWidth,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
placeholder: (_, _) =>
|
placeholder: (_, _) =>
|
||||||
Container(color: colorScheme.surface),
|
Container(color: colorScheme.surface),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
import 'package:spotiflac_android/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
||||||
@@ -69,7 +70,12 @@ class UnifiedLibraryItem {
|
|||||||
|
|
||||||
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
|
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
|
||||||
String? quality;
|
String? quality;
|
||||||
if (item.bitDepth != null && item.sampleRate != null) {
|
if (item.bitrate != null && item.bitrate! > 0) {
|
||||||
|
// Lossy format with bitrate
|
||||||
|
final fmt = item.format?.toUpperCase() ?? '';
|
||||||
|
quality = '$fmt ${item.bitrate}kbps'.trim();
|
||||||
|
} else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) {
|
||||||
|
// Lossless format with actual bit depth
|
||||||
quality =
|
quality =
|
||||||
'${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
|
'${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
|
||||||
}
|
}
|
||||||
@@ -105,6 +111,7 @@ class _GroupedAlbum {
|
|||||||
final String albumName;
|
final String albumName;
|
||||||
final String artistName;
|
final String artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
|
final String sampleFilePath;
|
||||||
final List<DownloadHistoryItem> tracks;
|
final List<DownloadHistoryItem> tracks;
|
||||||
final DateTime latestDownload;
|
final DateTime latestDownload;
|
||||||
final String searchKey;
|
final String searchKey;
|
||||||
@@ -113,6 +120,7 @@ class _GroupedAlbum {
|
|||||||
required this.albumName,
|
required this.albumName,
|
||||||
required this.artistName,
|
required this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
required this.sampleFilePath,
|
||||||
required this.tracks,
|
required this.tracks,
|
||||||
required this.latestDownload,
|
required this.latestDownload,
|
||||||
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
|
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||||
@@ -207,10 +215,25 @@ class _UnifiedCacheEntry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _QueueItemIdsSnapshot {
|
||||||
|
final List<String> ids;
|
||||||
|
|
||||||
|
const _QueueItemIdsSnapshot(this.ids);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is _QueueItemIdsSnapshot && listEquals(ids, other.ids);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hashAll(ids);
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
|
Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
|
||||||
final entries = (payload['entries'] as List).cast<List>();
|
final entries = (payload['entries'] as List).cast<List>();
|
||||||
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
|
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
|
||||||
final query = (payload['query'] as String?) ?? '';
|
final query = (payload['query'] as String?) ?? '';
|
||||||
|
final hasQuery = query.isNotEmpty;
|
||||||
|
|
||||||
final allIds = <String>[];
|
final allIds = <String>[];
|
||||||
final albumIds = <String>[];
|
final albumIds = <String>[];
|
||||||
@@ -219,10 +242,11 @@ Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
|
|||||||
for (final entry in entries) {
|
for (final entry in entries) {
|
||||||
final id = entry[0] as String;
|
final id = entry[0] as String;
|
||||||
final albumKey = entry[1] as String;
|
final albumKey = entry[1] as String;
|
||||||
final searchKey = entry[2] as String;
|
if (hasQuery) {
|
||||||
|
final searchKey = entry[2] as String;
|
||||||
if (query.isNotEmpty && !searchKey.contains(query)) {
|
if (!searchKey.contains(query)) {
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allIds.add(id);
|
allIds.add(id);
|
||||||
@@ -259,6 +283,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final ValueNotifier<bool> _alwaysMissingFileNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _alwaysMissingFileNotifier = ValueNotifier(false);
|
||||||
final Set<String> _pendingChecks = {};
|
final Set<String> _pendingChecks = {};
|
||||||
static const int _maxCacheSize = 500;
|
static const int _maxCacheSize = 500;
|
||||||
|
static const int _maxSearchIndexCacheSize = 4000;
|
||||||
|
bool _embeddedCoverRefreshScheduled = false;
|
||||||
|
|
||||||
bool _isSelectionMode = false;
|
bool _isSelectionMode = false;
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
@@ -290,8 +316,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_HistoryStats? _historyStatsCache;
|
_HistoryStats? _historyStatsCache;
|
||||||
final Map<String, String> _searchIndexCache = {};
|
final Map<String, String> _searchIndexCache = {};
|
||||||
final Map<String, String> _localSearchIndexCache = {};
|
final Map<String, String> _localSearchIndexCache = {};
|
||||||
Map<String, DownloadHistoryItem> _historyItemsById = {};
|
|
||||||
List<List<String>> _historyFilterEntries = const [];
|
|
||||||
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
|
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
|
||||||
List<DownloadHistoryItem>? _filterItemsCache;
|
List<DownloadHistoryItem>? _filterItemsCache;
|
||||||
String _filterQueryCache = '';
|
String _filterQueryCache = '';
|
||||||
@@ -379,32 +403,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_historyItemsCache = items;
|
_historyItemsCache = items;
|
||||||
_localLibraryItemsCache = localItems;
|
_localLibraryItemsCache = localItems;
|
||||||
_historyStatsCache = _buildHistoryStats(items, localItems);
|
_historyStatsCache = _buildHistoryStats(items, localItems);
|
||||||
_searchIndexCache
|
if (historyChanged) {
|
||||||
..clear()
|
_searchIndexCache.clear();
|
||||||
..addEntries(
|
}
|
||||||
items.map((item) => MapEntry(item.id, _buildSearchKey(item))),
|
|
||||||
);
|
|
||||||
if (localChanged) {
|
if (localChanged) {
|
||||||
_localSearchIndexCache
|
_localSearchIndexCache.clear();
|
||||||
..clear()
|
|
||||||
..addEntries(
|
|
||||||
localItems.map(
|
|
||||||
(item) => MapEntry(item.id, _buildLocalSearchKey(item)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_localFilterItemsCache = null;
|
_localFilterItemsCache = null;
|
||||||
_localFilterQueryCache = '';
|
_localFilterQueryCache = '';
|
||||||
_filteredLocalItemsCache = const [];
|
_filteredLocalItemsCache = const [];
|
||||||
}
|
}
|
||||||
_unifiedItemsCache.clear();
|
_unifiedItemsCache.clear();
|
||||||
_historyItemsById = {for (final item in items) item.id: item};
|
|
||||||
_historyFilterEntries = List<List<String>>.generate(items.length, (index) {
|
if (historyChanged) {
|
||||||
final item = items[index];
|
final validPaths = items
|
||||||
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
|
.map((item) => _cleanFilePath(item.filePath))
|
||||||
final albumKey =
|
.where((path) => path.isNotEmpty)
|
||||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
.toSet();
|
||||||
return [item.id, albumKey, searchKey];
|
DownloadedEmbeddedCoverResolver.invalidatePathsNotIn(validPaths);
|
||||||
}, growable: false);
|
}
|
||||||
_requestFilterRefresh();
|
_requestFilterRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +434,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _historySearchKeyForItem(DownloadHistoryItem item) {
|
||||||
|
final cached = _searchIndexCache[item.id];
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
final searchKey = _buildSearchKey(item);
|
||||||
|
_searchIndexCache[item.id] = searchKey;
|
||||||
|
while (_searchIndexCache.length > _maxSearchIndexCacheSize) {
|
||||||
|
_searchIndexCache.remove(_searchIndexCache.keys.first);
|
||||||
|
}
|
||||||
|
return searchKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _localSearchKeyForItem(LocalLibraryItem item) {
|
||||||
|
final cached = _localSearchIndexCache[item.id];
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
final searchKey = _buildLocalSearchKey(item);
|
||||||
|
_localSearchIndexCache[item.id] = searchKey;
|
||||||
|
while (_localSearchIndexCache.length > _maxSearchIndexCacheSize) {
|
||||||
|
_localSearchIndexCache.remove(_localSearchIndexCache.keys.first);
|
||||||
|
}
|
||||||
|
return searchKey;
|
||||||
|
}
|
||||||
|
|
||||||
List<LocalLibraryItem> _filterLocalItems(
|
List<LocalLibraryItem> _filterLocalItems(
|
||||||
List<LocalLibraryItem> items,
|
List<LocalLibraryItem> items,
|
||||||
String query,
|
String query,
|
||||||
@@ -430,11 +470,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
final filtered = items
|
final filtered = items
|
||||||
.where((item) {
|
.where((item) {
|
||||||
final searchKey =
|
final searchKey = _localSearchKeyForItem(item);
|
||||||
_localSearchIndexCache[item.id] ?? _buildLocalSearchKey(item);
|
|
||||||
if (!_localSearchIndexCache.containsKey(item.id)) {
|
|
||||||
_localSearchIndexCache[item.id] = searchKey;
|
|
||||||
}
|
|
||||||
return searchKey.contains(query);
|
return searchKey.contains(query);
|
||||||
})
|
})
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
@@ -507,15 +543,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final requestId = ++_filterRequestId;
|
final requestId = ++_filterRequestId;
|
||||||
|
final includeSearchKey = query.isNotEmpty;
|
||||||
|
final entries = List<List<String>>.generate(items.length, (index) {
|
||||||
|
final item = items[index];
|
||||||
|
final albumKey =
|
||||||
|
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||||
|
if (!includeSearchKey) {
|
||||||
|
return [item.id, albumKey];
|
||||||
|
}
|
||||||
|
final searchKey = _historySearchKeyForItem(item);
|
||||||
|
return [item.id, albumKey, searchKey];
|
||||||
|
}, growable: false);
|
||||||
final payload = <String, Object>{
|
final payload = <String, Object>{
|
||||||
'entries': _historyFilterEntries,
|
'entries': entries,
|
||||||
'albumCounts': albumCounts,
|
'albumCounts': albumCounts,
|
||||||
'query': query,
|
'query': query,
|
||||||
};
|
};
|
||||||
|
|
||||||
compute(_filterHistoryInIsolate, payload).then((result) {
|
compute(_filterHistoryInIsolate, payload).then((result) {
|
||||||
if (!mounted || requestId != _filterRequestId) return;
|
if (!mounted || requestId != _filterRequestId) return;
|
||||||
final itemsById = _historyItemsById;
|
final itemsById = {for (final item in items) item.id: item};
|
||||||
final filtered = <String, List<DownloadHistoryItem>>{};
|
final filtered = <String, List<DownloadHistoryItem>>{};
|
||||||
for (final entry in result.entries) {
|
for (final entry in result.entries) {
|
||||||
filtered[entry.key] = entry.value
|
filtered[entry.key] = entry.value
|
||||||
@@ -563,10 +610,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final query = searchQuery;
|
final query = searchQuery;
|
||||||
return items
|
return items
|
||||||
.where((item) {
|
.where((item) {
|
||||||
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
|
final searchKey = _historySearchKeyForItem(item);
|
||||||
if (!_searchIndexCache.containsKey(item.id)) {
|
|
||||||
_searchIndexCache[item.id] = searchKey;
|
|
||||||
}
|
|
||||||
return searchKey.contains(query);
|
return searchKey.contains(query);
|
||||||
})
|
})
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
@@ -646,13 +690,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getQualityBadgeText(String quality) {
|
String _getQualityBadgeText(String quality) {
|
||||||
if (quality.contains('bit')) {
|
final q = quality.trim().toLowerCase();
|
||||||
|
if (q.contains('bit')) {
|
||||||
return quality.split('/').first;
|
return quality.split('/').first;
|
||||||
}
|
}
|
||||||
final bitrateMatch = RegExp(r'(\d+)kbps').firstMatch(quality);
|
|
||||||
if (bitrateMatch != null) {
|
// Supports "MP3 320k", "Opus 256kbps", etc.
|
||||||
return '${bitrateMatch.group(1)}k';
|
final bitrateTextMatch = RegExp(
|
||||||
|
r'(\d+)\s*k(?:bps)?',
|
||||||
|
caseSensitive: false,
|
||||||
|
).firstMatch(quality);
|
||||||
|
if (bitrateTextMatch != null) {
|
||||||
|
return '${bitrateTextMatch.group(1)}k';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Supports legacy quality IDs like "opus_256" / "mp3_320".
|
||||||
|
final bitrateIdMatch = RegExp(r'_(\d+)$').firstMatch(q);
|
||||||
|
if (bitrateIdMatch != null) {
|
||||||
|
return '${bitrateIdMatch.group(1)}k';
|
||||||
|
}
|
||||||
|
|
||||||
return quality.split(' ').first;
|
return quality.split(' ').first;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,10 +739,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||||
final localLibraryDb = LibraryDatabase.instance;
|
final localLibraryDb = LibraryDatabase.instance;
|
||||||
|
final itemsById = {for (final item in allItems) item.id: item};
|
||||||
|
|
||||||
int deletedCount = 0;
|
int deletedCount = 0;
|
||||||
for (final id in _selectedIds) {
|
for (final id in _selectedIds) {
|
||||||
final item = allItems.where((e) => e.id == id).firstOrNull;
|
final item = itemsById[id];
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
try {
|
try {
|
||||||
final cleanPath = _cleanFilePath(item.filePath);
|
final cleanPath = _cleanFilePath(item.filePath);
|
||||||
@@ -725,11 +783,42 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
/// Strip EXISTS: prefix from file path (legacy history items)
|
/// Strip EXISTS: prefix from file path (legacy history items)
|
||||||
String _cleanFilePath(String? filePath) {
|
String _cleanFilePath(String? filePath) {
|
||||||
if (filePath == null) return '';
|
return DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
|
||||||
if (filePath.startsWith('EXISTS:')) {
|
}
|
||||||
return filePath.substring(7);
|
|
||||||
}
|
Future<int?> _readFileModTimeMillis(String? filePath) async {
|
||||||
return filePath;
|
return DownloadedEmbeddedCoverResolver.readFileModTimeMillis(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onEmbeddedCoverChanged() {
|
||||||
|
if (!mounted || _embeddedCoverRefreshScheduled) return;
|
||||||
|
_embeddedCoverRefreshScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_embeddedCoverRefreshScheduled = false;
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||||
|
String? filePath, {
|
||||||
|
int? beforeModTime,
|
||||||
|
bool force = false,
|
||||||
|
}) async {
|
||||||
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||||
|
filePath,
|
||||||
|
beforeModTime: beforeModTime,
|
||||||
|
force: force,
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _resolveDownloadedEmbeddedCoverPath(String? filePath) {
|
||||||
|
return DownloadedEmbeddedCoverResolver.resolve(
|
||||||
|
filePath,
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ValueListenable<bool> _fileExistsListenable(String? filePath) {
|
ValueListenable<bool> _fileExistsListenable(String? filePath) {
|
||||||
@@ -804,6 +893,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _fileExtLower(String filePath) {
|
||||||
|
final dotIndex = filePath.lastIndexOf('.');
|
||||||
|
if (dotIndex < 0 || dotIndex == filePath.length - 1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return filePath.substring(dotIndex + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _localQualityLabel(LocalLibraryItem item) {
|
||||||
|
if (item.bitrate != null && item.bitrate! > 0) {
|
||||||
|
return '${item.bitrate}kbps';
|
||||||
|
}
|
||||||
|
if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
|
||||||
|
}
|
||||||
|
|
||||||
List<UnifiedLibraryItem> _applyAdvancedFilters(
|
List<UnifiedLibraryItem> _applyAdvancedFilters(
|
||||||
List<UnifiedLibraryItem> items,
|
List<UnifiedLibraryItem> items,
|
||||||
) {
|
) {
|
||||||
@@ -841,7 +948,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_filterFormat != null) {
|
if (_filterFormat != null) {
|
||||||
final ext = item.filePath.split('.').last.toLowerCase();
|
final ext = _fileExtLower(item.filePath);
|
||||||
if (ext != _filterFormat) return false;
|
if (ext != _filterFormat) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,7 +1004,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
/// Check if a file path passes the current format filter
|
/// Check if a file path passes the current format filter
|
||||||
bool _passesFormatFilter(String filePath) {
|
bool _passesFormatFilter(String filePath) {
|
||||||
if (_filterFormat == null) return true;
|
if (_filterFormat == null) return true;
|
||||||
return filePath.split('.').last.toLowerCase() == _filterFormat;
|
return _fileExtLower(filePath) == _filterFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filter grouped download albums by search query + advanced filters
|
/// Filter grouped download albums by search query + advanced filters
|
||||||
@@ -922,15 +1029,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
// Filter tracks within the album by advanced filters
|
// Filter tracks within the album by advanced filters
|
||||||
if (_filterQuality != null || _filterFormat != null) {
|
if (_filterQuality != null || _filterFormat != null) {
|
||||||
final filteredTracks = album.tracks
|
var hasMatchingTrack = false;
|
||||||
.where((track) {
|
for (final track in album.tracks) {
|
||||||
if (!_passesQualityFilter(track.quality)) return false;
|
if (!_passesQualityFilter(track.quality)) continue;
|
||||||
if (!_passesFormatFilter(track.filePath)) return false;
|
if (!_passesFormatFilter(track.filePath)) continue;
|
||||||
return true;
|
hasMatchingTrack = true;
|
||||||
})
|
break;
|
||||||
.toList(growable: false);
|
}
|
||||||
|
|
||||||
if (filteredTracks.isEmpty) continue;
|
if (!hasMatchingTrack) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
result.add(album);
|
result.add(album);
|
||||||
@@ -979,20 +1086,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
// Filter tracks within the album by advanced filters
|
// Filter tracks within the album by advanced filters
|
||||||
if (_filterQuality != null || _filterFormat != null) {
|
if (_filterQuality != null || _filterFormat != null) {
|
||||||
final filteredTracks = album.tracks
|
var hasMatchingTrack = false;
|
||||||
.where((track) {
|
for (final track in album.tracks) {
|
||||||
String? quality;
|
if (!_passesQualityFilter(_localQualityLabel(track))) continue;
|
||||||
if (track.bitDepth != null && track.sampleRate != null) {
|
if (!_passesFormatFilter(track.filePath)) continue;
|
||||||
quality =
|
hasMatchingTrack = true;
|
||||||
'${track.bitDepth}bit/${(track.sampleRate! / 1000).toStringAsFixed(1)}kHz';
|
break;
|
||||||
}
|
}
|
||||||
if (!_passesQualityFilter(quality)) return false;
|
|
||||||
if (!_passesFormatFilter(track.filePath)) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.toList(growable: false);
|
|
||||||
|
|
||||||
if (filteredTracks.isEmpty) continue;
|
if (!hasMatchingTrack) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
result.add(album);
|
result.add(album);
|
||||||
@@ -1022,7 +1124,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
|
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
|
||||||
final formats = <String>{};
|
final formats = <String>{};
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
final ext = item.filePath.split('.').last.toLowerCase();
|
final ext = _fileExtLower(item.filePath);
|
||||||
if (['flac', 'mp3', 'm4a', 'opus', 'ogg', 'wav', 'aiff'].contains(ext)) {
|
if (['flac', 'mp3', 'm4a', 'opus', 'ogg', 'wav', 'aiff'].contains(ext)) {
|
||||||
formats.add(ext);
|
formats.add(ext);
|
||||||
}
|
}
|
||||||
@@ -1274,13 +1376,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||||
precacheImage(
|
precacheImage(
|
||||||
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
|
ResizeImage(
|
||||||
|
CachedNetworkImageProvider(
|
||||||
|
url,
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
),
|
||||||
|
width: targetSize,
|
||||||
|
height: targetSize,
|
||||||
|
),
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToMetadataScreen(DownloadItem item) {
|
Future<void> _navigateToMetadataScreen(DownloadItem item) async {
|
||||||
final historyItem = ref
|
final historyItem = ref
|
||||||
.read(downloadHistoryProvider)
|
.read(downloadHistoryProvider)
|
||||||
.items
|
.items
|
||||||
@@ -1298,10 +1411,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
_precacheCover(historyItem.coverUrl);
|
_precacheCover(historyItem.coverUrl);
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
Navigator.push(
|
final beforeModTime = await _readFileModTimeMillis(historyItem.filePath);
|
||||||
context,
|
if (!mounted) return;
|
||||||
|
final result = await navigator.push(
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
@@ -1310,14 +1425,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
),
|
),
|
||||||
).then((_) => _searchFocusNode.unfocus());
|
);
|
||||||
|
_searchFocusNode.unfocus();
|
||||||
|
if (result == true) {
|
||||||
|
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||||
|
historyItem.filePath,
|
||||||
|
beforeModTime: beforeModTime,
|
||||||
|
force: true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||||
|
historyItem.filePath,
|
||||||
|
beforeModTime: beforeModTime,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) {
|
Future<void> _navigateToHistoryMetadataScreen(
|
||||||
|
DownloadHistoryItem item,
|
||||||
|
) async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
_precacheCover(item.coverUrl);
|
_precacheCover(item.coverUrl);
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
Navigator.push(
|
final beforeModTime = await _readFileModTimeMillis(item.filePath);
|
||||||
context,
|
if (!mounted) return;
|
||||||
|
final result = await navigator.push(
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
@@ -1326,7 +1458,20 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
),
|
),
|
||||||
).then((_) => _searchFocusNode.unfocus());
|
);
|
||||||
|
_searchFocusNode.unfocus();
|
||||||
|
if (result == true) {
|
||||||
|
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||||
|
item.filePath,
|
||||||
|
beforeModTime: beforeModTime,
|
||||||
|
force: true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _scheduleDownloadedEmbeddedCoverRefreshForPath(
|
||||||
|
item.filePath,
|
||||||
|
beforeModTime: beforeModTime,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToLocalMetadataScreen(LocalLibraryItem item) {
|
void _navigateToLocalMetadataScreen(LocalLibraryItem item) {
|
||||||
@@ -1355,10 +1500,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
if (searchQuery.isNotEmpty) {
|
if (searchQuery.isNotEmpty) {
|
||||||
final query = searchQuery;
|
final query = searchQuery;
|
||||||
filteredItems = items.where((item) {
|
filteredItems = items.where((item) {
|
||||||
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
|
final searchKey = _historySearchKeyForItem(item);
|
||||||
if (!_searchIndexCache.containsKey(item.id)) {
|
|
||||||
_searchIndexCache[item.id] = searchKey;
|
|
||||||
}
|
|
||||||
return searchKey.contains(query);
|
return searchKey.contains(query);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
@@ -1421,6 +1563,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
albumName: tracks.first.albumName,
|
albumName: tracks.first.albumName,
|
||||||
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
|
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
|
||||||
coverUrl: tracks.first.coverUrl,
|
coverUrl: tracks.first.coverUrl,
|
||||||
|
sampleFilePath: tracks.first.filePath,
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
latestDownload: tracks
|
latestDownload: tracks
|
||||||
.map((t) => t.downloadedAt)
|
.map((t) => t.downloadedAt)
|
||||||
@@ -1544,7 +1687,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_initializePageController();
|
_initializePageController();
|
||||||
|
|
||||||
final hasQueueItems = ref.watch(
|
final hasQueueItems = ref.watch(
|
||||||
downloadQueueProvider.select((s) => s.items.isNotEmpty),
|
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty),
|
||||||
);
|
);
|
||||||
final allHistoryItems = ref.watch(
|
final allHistoryItems = ref.watch(
|
||||||
downloadHistoryProvider.select((s) => s.items),
|
downloadHistoryProvider.select((s) => s.items),
|
||||||
@@ -1572,6 +1715,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_buildHistoryStats(allHistoryItems, localLibraryItems);
|
_buildHistoryStats(allHistoryItems, localLibraryItems);
|
||||||
final groupedAlbums = historyStats.groupedAlbums;
|
final groupedAlbums = historyStats.groupedAlbums;
|
||||||
final groupedLocalAlbums = historyStats.groupedLocalAlbums;
|
final groupedLocalAlbums = historyStats.groupedLocalAlbums;
|
||||||
|
final filteredGroupedAlbums = _filterGroupedAlbums(
|
||||||
|
groupedAlbums,
|
||||||
|
_searchQuery,
|
||||||
|
);
|
||||||
|
final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums(
|
||||||
|
groupedLocalAlbums,
|
||||||
|
_searchQuery,
|
||||||
|
);
|
||||||
final albumCount = historyStats.totalAlbumCount;
|
final albumCount = historyStats.totalAlbumCount;
|
||||||
final singleCount = historyStats.totalSingleTracks;
|
final singleCount = historyStats.totalSingleTracks;
|
||||||
final filterDataCache = <String, _FilterContentData>{};
|
final filterDataCache = <String, _FilterContentData>{};
|
||||||
@@ -1582,8 +1733,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
() => _computeFilterContentData(
|
() => _computeFilterContentData(
|
||||||
filterMode: filterMode,
|
filterMode: filterMode,
|
||||||
allHistoryItems: allHistoryItems,
|
allHistoryItems: allHistoryItems,
|
||||||
groupedAlbums: groupedAlbums,
|
filteredGroupedAlbums: filteredGroupedAlbums,
|
||||||
groupedLocalAlbums: groupedLocalAlbums,
|
filteredGroupedLocalAlbums: filteredGroupedLocalAlbums,
|
||||||
albumCounts: historyStats.albumCounts,
|
albumCounts: historyStats.albumCounts,
|
||||||
localAlbumCounts: historyStats.localAlbumCounts,
|
localAlbumCounts: historyStats.localAlbumCounts,
|
||||||
localLibraryItems: localLibraryItems,
|
localLibraryItems: localLibraryItems,
|
||||||
@@ -1647,7 +1798,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Search bar - always at top
|
// Search bar - always at top
|
||||||
if (allHistoryItems.isNotEmpty || hasQueueItems || localLibraryItems.isNotEmpty)
|
if (allHistoryItems.isNotEmpty ||
|
||||||
|
hasQueueItems ||
|
||||||
|
localLibraryItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
@@ -1972,8 +2125,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_FilterContentData _computeFilterContentData({
|
_FilterContentData _computeFilterContentData({
|
||||||
required String filterMode,
|
required String filterMode,
|
||||||
required List<DownloadHistoryItem> allHistoryItems,
|
required List<DownloadHistoryItem> allHistoryItems,
|
||||||
required List<_GroupedAlbum> groupedAlbums,
|
required List<_GroupedAlbum> filteredGroupedAlbums,
|
||||||
required List<_GroupedLocalAlbum> groupedLocalAlbums,
|
required List<_GroupedLocalAlbum> filteredGroupedLocalAlbums,
|
||||||
required Map<String, int> albumCounts,
|
required Map<String, int> albumCounts,
|
||||||
required Map<String, int> localAlbumCounts,
|
required Map<String, int> localAlbumCounts,
|
||||||
required List<LocalLibraryItem> localLibraryItems,
|
required List<LocalLibraryItem> localLibraryItems,
|
||||||
@@ -1988,16 +2141,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
filterMode: filterMode,
|
filterMode: filterMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
final searchQuery = _searchQuery;
|
|
||||||
final filteredGroupedAlbums = _filterGroupedAlbums(
|
|
||||||
groupedAlbums,
|
|
||||||
searchQuery,
|
|
||||||
);
|
|
||||||
final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums(
|
|
||||||
groupedLocalAlbums,
|
|
||||||
searchQuery,
|
|
||||||
);
|
|
||||||
|
|
||||||
final unifiedItems = _getUnifiedItems(
|
final unifiedItems = _getUnifiedItems(
|
||||||
filterMode: filterMode,
|
filterMode: filterMode,
|
||||||
historyItems: historyItems,
|
historyItems: historyItems,
|
||||||
@@ -2023,7 +2166,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
return Consumer(
|
return Consumer(
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
final queueCount = ref.watch(
|
final queueCount = ref.watch(
|
||||||
downloadQueueProvider.select((s) => s.items.length),
|
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.length),
|
||||||
);
|
);
|
||||||
if (queueCount == 0) {
|
if (queueCount == 0) {
|
||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
@@ -2054,20 +2197,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
Widget _buildQueueItemsSliver(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildQueueItemsSliver(BuildContext context, ColorScheme colorScheme) {
|
||||||
return Consumer(
|
return Consumer(
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
final queueItems = ref.watch(
|
final queueIdsSnapshot = ref.watch(
|
||||||
downloadQueueProvider.select((s) => s.items),
|
downloadQueueLookupProvider.select(
|
||||||
|
(lookup) => _QueueItemIdsSnapshot(lookup.itemIds),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (queueItems.isEmpty) {
|
if (queueIdsSnapshot.ids.isEmpty) {
|
||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
}
|
}
|
||||||
return SliverList(
|
return SliverList(
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
final item = queueItems[index];
|
final itemId = queueIdsSnapshot.ids[index];
|
||||||
return KeyedSubtree(
|
return _QueueItemSliverRow(
|
||||||
key: ValueKey(item.id),
|
key: ValueKey(itemId),
|
||||||
child: _buildQueueItem(context, item, colorScheme),
|
itemId: itemId,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
itemBuilder: _buildQueueItem,
|
||||||
);
|
);
|
||||||
}, childCount: queueItems.length),
|
}, childCount: queueIdsSnapshot.ids.length),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -2093,7 +2240,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
if (totalTrackCount > 0 && !hasQueueItems && filterMode == 'all')
|
if (totalTrackCount > 0 && filterMode == 'all')
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -2143,7 +2290,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
if ((filteredGroupedAlbums.isNotEmpty ||
|
if ((filteredGroupedAlbums.isNotEmpty ||
|
||||||
filteredGroupedLocalAlbums.isNotEmpty) &&
|
filteredGroupedLocalAlbums.isNotEmpty) &&
|
||||||
!hasQueueItems &&
|
|
||||||
filterMode == 'albums')
|
filterMode == 'albums')
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -2180,7 +2326,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
// Albums empty state with filter button
|
// Albums empty state with filter button
|
||||||
if (filteredGroupedAlbums.isEmpty &&
|
if (filteredGroupedAlbums.isEmpty &&
|
||||||
filteredGroupedLocalAlbums.isEmpty &&
|
filteredGroupedLocalAlbums.isEmpty &&
|
||||||
!hasQueueItems &&
|
|
||||||
filterMode == 'albums' &&
|
filterMode == 'albums' &&
|
||||||
(historyItems.isNotEmpty || localLibraryItems.isNotEmpty))
|
(historyItems.isNotEmpty || localLibraryItems.isNotEmpty))
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -2331,7 +2476,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Singles filter - show unified items (downloaded + local singles)
|
// Singles filter - show unified items (downloaded + local singles)
|
||||||
if (filterMode == 'singles' && !hasQueueItems)
|
if (filterMode == 'singles')
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -2559,6 +2704,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
_GroupedAlbum album,
|
_GroupedAlbum album,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
|
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
|
||||||
|
album.sampleFilePath,
|
||||||
|
);
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _navigateToDownloadedAlbum(album),
|
onTap: () => _navigateToDownloadedAlbum(album),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -2569,7 +2717,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: album.coverUrl != null
|
child: embeddedCoverPath != null
|
||||||
|
? Image.file(
|
||||||
|
File(embeddedCoverPath),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
cacheWidth: 300,
|
||||||
|
cacheHeight: 300,
|
||||||
|
errorBuilder: (context, error, stackTrace) =>
|
||||||
|
Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.album,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: album.coverUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: album.coverUrl!,
|
imageUrl: album.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
@@ -2946,13 +3114,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
// show bytes downloaded instead of percentage
|
// show bytes downloaded instead of percentage
|
||||||
item.progress > 0
|
item.progress > 0
|
||||||
? (item.speedMBps > 0
|
? (item.speedMBps > 0
|
||||||
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||||
: '${(item.progress * 100).toStringAsFixed(0)}%')
|
: '${(item.progress * 100).toStringAsFixed(0)}%')
|
||||||
: (item.bytesReceived > 0
|
: (item.bytesReceived > 0
|
||||||
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||||
: (item.speedMBps > 0
|
: (item.speedMBps > 0
|
||||||
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||||
: 'Starting...')),
|
: 'Starting...')),
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
@@ -3139,6 +3307,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
double size,
|
double size,
|
||||||
) {
|
) {
|
||||||
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
||||||
|
if (isDownloaded) {
|
||||||
|
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
|
||||||
|
item.filePath,
|
||||||
|
);
|
||||||
|
if (embeddedCoverPath != null) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.file(
|
||||||
|
File(embeddedCoverPath),
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
cacheWidth: (size * 2).toInt(),
|
||||||
|
cacheHeight: (size * 2).toInt(),
|
||||||
|
errorBuilder: (context, error, stackTrace) =>
|
||||||
|
_buildPlaceholderCover(colorScheme, size, isDownloaded),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Network URL cover (downloaded items)
|
// Network URL cover (downloaded items)
|
||||||
if (item.coverUrl != null) {
|
if (item.coverUrl != null) {
|
||||||
@@ -3220,6 +3408,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
final isDownloaded = item.source == LibraryItemSource.downloaded;
|
||||||
|
if (isDownloaded) {
|
||||||
|
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
|
||||||
|
item.filePath,
|
||||||
|
);
|
||||||
|
if (embeddedCoverPath != null) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.file(
|
||||||
|
File(embeddedCoverPath),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
cacheWidth: 200,
|
||||||
|
cacheHeight: 200,
|
||||||
|
errorBuilder: (context, error, stackTrace) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Network URL cover (downloaded items)
|
// Network URL cover (downloaded items)
|
||||||
if (item.coverUrl != null) {
|
if (item.coverUrl != null) {
|
||||||
@@ -3668,6 +3880,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _QueueItemSliverRow extends ConsumerWidget {
|
||||||
|
final String itemId;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
final Widget Function(BuildContext, DownloadItem, ColorScheme) itemBuilder;
|
||||||
|
|
||||||
|
const _QueueItemSliverRow({
|
||||||
|
super.key,
|
||||||
|
required this.itemId,
|
||||||
|
required this.colorScheme,
|
||||||
|
required this.itemBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final item = ref.watch(
|
||||||
|
downloadQueueLookupProvider.select((lookup) => lookup.byItemId[itemId]),
|
||||||
|
);
|
||||||
|
if (item == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return RepaintBoundary(child: itemBuilder(context, item, colorScheme));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _FilterChip extends StatelessWidget {
|
class _FilterChip extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final int count;
|
final int count;
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
if (widget.query.isNotEmpty) {
|
if (widget.query.isNotEmpty) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
ref.read(trackProvider.notifier).search(widget.query, metadataSource: settings.metadataSource);
|
ref
|
||||||
|
.read(trackProvider.notifier)
|
||||||
|
.search(widget.query, metadataSource: settings.metadataSource);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,19 +43,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
final query = _searchController.text.trim();
|
final query = _searchController.text.trim();
|
||||||
if (query.isNotEmpty) {
|
if (query.isNotEmpty) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
ref
|
||||||
|
.read(trackProvider.notifier)
|
||||||
|
.search(query, metadataSource: settings.metadataSource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _downloadTrack(Track track) {
|
void _downloadTrack(Track track) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(
|
ref
|
||||||
track,
|
.read(downloadQueueProvider.notifier)
|
||||||
settings.defaultService,
|
.addToQueue(track, settings.defaultService);
|
||||||
);
|
ScaffoldMessenger.of(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
context,
|
||||||
SnackBar(content: Text('Added "${track.name}" to queue')),
|
).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -78,10 +81,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
autofocus: widget.query.isEmpty,
|
autofocus: widget.query.isEmpty,
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(icon: const Icon(Icons.search), onPressed: _search),
|
||||||
icon: const Icon(Icons.search),
|
|
||||||
onPressed: _search,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
@@ -115,11 +115,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.search, size: 64, color: colorScheme.onSurfaceVariant),
|
||||||
Icons.search,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Search for tracks',
|
'Search for tracks',
|
||||||
@@ -137,11 +133,13 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
leading: track.coverUrl != null
|
leading: track.coverUrl != null
|
||||||
? ClipRRect(
|
? ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
imageUrl: track.coverUrl!,
|
imageUrl: track.coverUrl!,
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 144,
|
||||||
|
memCacheHeight: 144,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -152,7 +150,10 @@ child: CachedNetworkImage(
|
|||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
child: Icon(
|
||||||
|
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: Column(
|
subtitle: Column(
|
||||||
|
|||||||
@@ -384,6 +384,8 @@ class _ContributorItem extends StatelessWidget {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 120,
|
||||||
|
memCacheHeight: 120,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
|
|||||||
@@ -60,19 +60,30 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<_CacheOverview> _buildOverview() async {
|
Future<_CacheOverview> _buildOverview() async {
|
||||||
final appCacheDir = await getApplicationCacheDirectory();
|
final appCacheDirFuture = getApplicationCacheDirectory();
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDirFuture = getTemporaryDirectory();
|
||||||
|
final appSupportDirFuture = getApplicationSupportDirectory();
|
||||||
|
final coverStatsFuture = CoverCacheManager.getStats();
|
||||||
|
final prefsFuture = SharedPreferences.getInstance();
|
||||||
|
final trackCacheEntriesFuture = _getTrackCacheSizeSafe();
|
||||||
|
|
||||||
|
final appCacheDir = await appCacheDirFuture;
|
||||||
|
final tempDir = await tempDirFuture;
|
||||||
final appCachePath = p.normalize(appCacheDir.path);
|
final appCachePath = p.normalize(appCacheDir.path);
|
||||||
final tempPath = p.normalize(tempDir.path);
|
final tempPath = p.normalize(tempDir.path);
|
||||||
final tempIsSameAsAppCache = appCachePath == tempPath;
|
final tempIsSameAsAppCache = appCachePath == tempPath;
|
||||||
|
|
||||||
final appCacheStats = await _scanDirectory(Directory(appCachePath));
|
final appCacheStatsFuture = _scanDirectory(Directory(appCachePath));
|
||||||
final tempStats = tempIsSameAsAppCache
|
final tempStatsFuture = tempIsSameAsAppCache
|
||||||
? null
|
? Future<_DirectoryStats?>.value(null)
|
||||||
: await _scanDirectory(Directory(tempPath));
|
: _scanDirectory(Directory(tempPath));
|
||||||
final coverStats = await CoverCacheManager.getStats();
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final appSupportDir = await appSupportDirFuture;
|
||||||
|
final libraryCoverStatsFuture = _scanDirectory(
|
||||||
|
Directory('${appSupportDir.path}/library_covers'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final prefs = await prefsFuture;
|
||||||
final explorePayload = prefs.getString(_exploreCacheKey);
|
final explorePayload = prefs.getString(_exploreCacheKey);
|
||||||
final exploreTs = prefs.getInt(_exploreCacheTsKey);
|
final exploreTs = prefs.getInt(_exploreCacheTsKey);
|
||||||
var exploreBytes = 0;
|
var exploreBytes = 0;
|
||||||
@@ -84,16 +95,11 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
|||||||
}
|
}
|
||||||
final hasExploreCache = exploreBytes > 0;
|
final hasExploreCache = exploreBytes > 0;
|
||||||
|
|
||||||
int trackCacheEntries;
|
final appCacheStats = await appCacheStatsFuture;
|
||||||
try {
|
final tempStats = await tempStatsFuture;
|
||||||
trackCacheEntries = await PlatformBridge.getTrackCacheSize();
|
final coverStats = await coverStatsFuture;
|
||||||
} catch (_) {
|
final libraryCoverStats = await libraryCoverStatsFuture;
|
||||||
trackCacheEntries = 0;
|
final trackCacheEntries = await trackCacheEntriesFuture;
|
||||||
}
|
|
||||||
|
|
||||||
final appSupportDir = await getApplicationSupportDirectory();
|
|
||||||
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
|
|
||||||
final libraryCoverStats = await _scanDirectory(libraryCoverDir);
|
|
||||||
|
|
||||||
return _CacheOverview(
|
return _CacheOverview(
|
||||||
appCachePath: appCachePath,
|
appCachePath: appCachePath,
|
||||||
@@ -132,16 +138,37 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
|||||||
return _DirectoryStats(fileCount: fileCount, totalSizeBytes: totalSize);
|
return _DirectoryStats(fileCount: fileCount, totalSizeBytes: totalSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> _getTrackCacheSizeSafe() async {
|
||||||
|
try {
|
||||||
|
return await PlatformBridge.getTrackCacheSize();
|
||||||
|
} catch (_) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _clearDirectoryContents(String path) async {
|
Future<void> _clearDirectoryContents(String path) async {
|
||||||
final directory = Directory(path);
|
final directory = Directory(path);
|
||||||
if (!await directory.exists()) return;
|
if (!await directory.exists()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final entities = directory.listSync(followLinks: false);
|
final entities = <FileSystemEntity>[];
|
||||||
for (final entity in entities) {
|
await for (final entity in directory.list(followLinks: false)) {
|
||||||
try {
|
entities.add(entity);
|
||||||
await entity.delete(recursive: true);
|
}
|
||||||
} catch (_) {}
|
|
||||||
|
const deleteChunkSize = 24;
|
||||||
|
for (var i = 0; i < entities.length; i += deleteChunkSize) {
|
||||||
|
final end = (i + deleteChunkSize < entities.length)
|
||||||
|
? i + deleteChunkSize
|
||||||
|
: entities.length;
|
||||||
|
final chunk = entities.sublist(i, end);
|
||||||
|
await Future.wait(
|
||||||
|
chunk.map((entity) async {
|
||||||
|
try {
|
||||||
|
await entity.delete(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
@@ -583,7 +610,9 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
|||||||
subtitle: _buildSubtitle(
|
subtitle: _buildSubtitle(
|
||||||
context.l10n.cacheTrackLookupDesc,
|
context.l10n.cacheTrackLookupDesc,
|
||||||
overview.trackCacheEntries > 0
|
overview.trackCacheEntries > 0
|
||||||
? context.l10n.cacheEntries(overview.trackCacheEntries)
|
? context.l10n.cacheEntries(
|
||||||
|
overview.trackCacheEntries,
|
||||||
|
)
|
||||||
: context.l10n.cacheNoData,
|
: context.l10n.cacheNoData,
|
||||||
),
|
),
|
||||||
trailing: _buildClearTrailing(
|
trailing: _buildClearTrailing(
|
||||||
@@ -611,7 +640,8 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
|||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.cleaning_services_outlined,
|
icon: Icons.cleaning_services_outlined,
|
||||||
title: context.l10n.cacheCleanupUnused,
|
title: context.l10n.cacheCleanupUnused,
|
||||||
subtitle: '${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
|
subtitle:
|
||||||
|
'${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
|
||||||
trailing: _buildClearTrailing(
|
trailing: _buildClearTrailing(
|
||||||
'cleanup_unused',
|
'cleanup_unused',
|
||||||
_cleanupUnusedData,
|
_cleanupUnusedData,
|
||||||
|
|||||||
@@ -202,9 +202,11 @@ class _RecentDonorsCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_DonorTile(name: 'J', colorScheme: colorScheme),
|
_DonorTile(name: 'J', colorScheme: colorScheme),
|
||||||
_DonorTile(name: 'Julian', colorScheme: colorScheme),
|
_DonorTile(name: 'Julian', colorScheme: colorScheme),
|
||||||
|
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
|
||||||
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
||||||
|
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
|
||||||
_DonorTile(
|
_DonorTile(
|
||||||
name: '283Fabio',
|
name: 'Elias el Autentico',
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -255,21 +257,6 @@ class _DonateLinksCard extends StatelessWidget {
|
|||||||
endIndent: 16,
|
endIndent: 16,
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
_DonateCardItem(
|
|
||||||
title: 'Buy Me a Coffee',
|
|
||||||
subtitle: 'buymeacoffee.com/zarzet',
|
|
||||||
customIcon: const BmacIcon(size: 22, color: Colors.black87),
|
|
||||||
color: const Color(0xFFFFDD00),
|
|
||||||
url: AppInfo.bmacUrl,
|
|
||||||
colorScheme: colorScheme,
|
|
||||||
),
|
|
||||||
Divider(
|
|
||||||
height: 1,
|
|
||||||
thickness: 1,
|
|
||||||
indent: 74,
|
|
||||||
endIndent: 16,
|
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
_DonateCardItem(
|
_DonateCardItem(
|
||||||
title: 'GitHub Sponsors',
|
title: 'GitHub Sponsors',
|
||||||
subtitle: 'github.com/sponsors/zarzet',
|
subtitle: 'github.com/sponsors/zarzet',
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
static const _builtInServices = ['tidal', 'qobuz'];
|
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||||
int _androidSdkVersion = 0;
|
int _androidSdkVersion = 0;
|
||||||
bool _hasAllFilesAccess = false;
|
bool _hasAllFilesAccess = false;
|
||||||
|
bool _artistFolderFiltersExpanded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -248,7 +249,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Select Tidal or Qobuz above to configure quality',
|
'Select Tidal, Qobuz, or Amazon above to configure quality',
|
||||||
style: Theme.of(context).textTheme.bodySmall
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -363,6 +364,64 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
onChanged: (value) => ref
|
onChanged: (value) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setUseAlbumArtistForFolders(value),
|
.setUseAlbumArtistForFolders(value),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.filter_alt_outlined,
|
||||||
|
title: 'Artist Name Filters',
|
||||||
|
subtitle: _getArtistFolderFilterSubtitle(
|
||||||
|
context,
|
||||||
|
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||||
|
filterAlbumArtistContributors:
|
||||||
|
settings.filterContributingArtistsInAlbumArtist,
|
||||||
|
),
|
||||||
|
trailing: Icon(
|
||||||
|
_artistFolderFiltersExpanded
|
||||||
|
? Icons.expand_less
|
||||||
|
: Icons.expand_more,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_artistFolderFiltersExpanded =
|
||||||
|
!_artistFolderFiltersExpanded;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
showDivider: !_artistFolderFiltersExpanded,
|
||||||
|
),
|
||||||
|
if (_artistFolderFiltersExpanded)
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
title: context.l10n.downloadUsePrimaryArtistOnly,
|
||||||
|
subtitle: settings.usePrimaryArtistOnly
|
||||||
|
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
|
||||||
|
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
|
||||||
|
value: settings.usePrimaryArtistOnly,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setUsePrimaryArtistOnly(value),
|
||||||
|
),
|
||||||
|
if (_artistFolderFiltersExpanded)
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.group_remove_outlined,
|
||||||
|
title: 'Filter contributing artists in Album Artist',
|
||||||
|
subtitle: settings.filterContributingArtistsInAlbumArtist
|
||||||
|
? 'Album Artist metadata uses primary artist only'
|
||||||
|
: 'Keep full Album Artist metadata value',
|
||||||
|
value: settings.filterContributingArtistsInAlbumArtist,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFilterContributingArtistsInAlbumArtist(value),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
title: context.l10n.downloadUsePrimaryArtistOnly,
|
||||||
|
subtitle: settings.usePrimaryArtistOnly
|
||||||
|
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
|
||||||
|
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
|
||||||
|
value: settings.usePrimaryArtistOnly,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setUsePrimaryArtistOnly(value),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -573,14 +632,28 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
final controller = TextEditingController(text: current);
|
final controller = TextEditingController(text: current);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
final tags = [
|
final basicTags = [
|
||||||
'{artist}',
|
'{artist}',
|
||||||
'{title}',
|
'{title}',
|
||||||
'{album}',
|
'{album}',
|
||||||
'{track}',
|
'{track}',
|
||||||
'{year}',
|
'{year}',
|
||||||
|
'{date}',
|
||||||
'{disc}',
|
'{disc}',
|
||||||
];
|
];
|
||||||
|
final advancedTags = [
|
||||||
|
'{track_raw}',
|
||||||
|
'{track:02}',
|
||||||
|
'{track:1}',
|
||||||
|
'{date:%Y}',
|
||||||
|
'{date:%Y-%m-%d}',
|
||||||
|
'{disc_raw}',
|
||||||
|
'{disc:02}',
|
||||||
|
];
|
||||||
|
var showAdvancedTags = RegExp(
|
||||||
|
r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}',
|
||||||
|
caseSensitive: false,
|
||||||
|
).hasMatch(current);
|
||||||
|
|
||||||
void insertTag(String tag) {
|
void insertTag(String tag) {
|
||||||
final text = controller.text;
|
final text = controller.text;
|
||||||
@@ -612,130 +685,164 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
),
|
),
|
||||||
builder: (context) => Padding(
|
builder: (context) => StatefulBuilder(
|
||||||
padding: EdgeInsets.only(
|
builder: (context, setModalState) => Padding(
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
padding: EdgeInsets.only(
|
||||||
),
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
child: SingleChildScrollView(
|
),
|
||||||
child: SafeArea(
|
child: SingleChildScrollView(
|
||||||
child: Padding(
|
child: SafeArea(
|
||||||
padding: const EdgeInsets.all(24),
|
child: Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(24),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
Center(
|
children: [
|
||||||
child: Container(
|
Center(
|
||||||
width: 32,
|
child: Container(
|
||||||
height: 4,
|
width: 32,
|
||||||
margin: const EdgeInsets.only(bottom: 24),
|
height: 4,
|
||||||
decoration: BoxDecoration(
|
margin: const EdgeInsets.only(bottom: 24),
|
||||||
color: colorScheme.outlineVariant,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(2),
|
color: colorScheme.outlineVariant,
|
||||||
),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
context.l10n.filenameFormat,
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Customize how your files are named.',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
TextField(
|
|
||||||
controller: controller,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: '{artist} - {title}',
|
|
||||||
filled: true,
|
|
||||||
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
|
||||||
alpha: 0.3,
|
|
||||||
),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
autofocus: true,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
Text(
|
|
||||||
'Tap to insert tag:',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: tags.map((tag) {
|
|
||||||
return ActionChip(
|
|
||||||
label: Text(tag),
|
|
||||||
onPressed: () => insertTag(tag),
|
|
||||||
backgroundColor: colorScheme.surfaceContainerHighest
|
|
||||||
.withValues(alpha: 0.5),
|
|
||||||
side: BorderSide.none,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
labelStyle: TextStyle(
|
),
|
||||||
color: colorScheme.onSurface,
|
),
|
||||||
fontWeight: FontWeight.w500,
|
Text(
|
||||||
|
context.l10n.filenameFormat,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Customize how your files are named.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '{artist} - {title}',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}).toList(),
|
autofocus: true,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
Text(
|
||||||
|
'Tap to insert tag:',
|
||||||
Row(
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
children: [
|
fontWeight: FontWeight.bold,
|
||||||
Expanded(
|
),
|
||||||
child: TextButton(
|
),
|
||||||
onPressed: () => Navigator.pop(context),
|
const SizedBox(height: 12),
|
||||||
style: TextButton.styleFrom(
|
Wrap(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
spacing: 8,
|
||||||
shape: RoundedRectangleBorder(
|
runSpacing: 8,
|
||||||
borderRadius: BorderRadius.circular(16),
|
children: basicTags.map((tag) {
|
||||||
),
|
return ActionChip(
|
||||||
|
label: Text(tag),
|
||||||
|
onPressed: () => insertTag(tag),
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
side: BorderSide.none,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Text(context.l10n.dialogCancel),
|
labelStyle: TextStyle(
|
||||||
),
|
color: colorScheme.onSurface,
|
||||||
),
|
fontWeight: FontWeight.w500,
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: () {
|
|
||||||
ref
|
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setFilenameFormat(controller.text);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Text(context.l10n.dialogSave),
|
);
|
||||||
),
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwitchListTile(
|
||||||
|
value: showAdvancedTags,
|
||||||
|
onChanged: (value) =>
|
||||||
|
setModalState(() => showAdvancedTags = value),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(context.l10n.filenameShowAdvancedTags),
|
||||||
|
subtitle: Text(
|
||||||
|
context.l10n.filenameShowAdvancedTagsDescription,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showAdvancedTags) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: advancedTags.map((tag) {
|
||||||
|
return ActionChip(
|
||||||
|
label: Text(tag),
|
||||||
|
onPressed: () => insertTag(tag),
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
side: BorderSide.none,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 32),
|
||||||
],
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(context.l10n.dialogCancel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFilenameFormat(controller.text);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(context.l10n.dialogSave),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -925,7 +1032,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
if (ctx.mounted) {
|
if (ctx.mounted) {
|
||||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported),
|
content: Text(
|
||||||
|
validation.errorReason ??
|
||||||
|
context.l10n.setupIcloudNotSupported,
|
||||||
|
),
|
||||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||||
duration: const Duration(seconds: 4),
|
duration: const Duration(seconds: 4),
|
||||||
),
|
),
|
||||||
@@ -988,6 +1098,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getArtistFolderFilterSubtitle(
|
||||||
|
BuildContext context, {
|
||||||
|
required bool usePrimaryArtistOnly,
|
||||||
|
required bool filterAlbumArtistContributors,
|
||||||
|
}) {
|
||||||
|
final statuses = <String>[
|
||||||
|
usePrimaryArtistOnly ? 'Primary only: On' : 'Primary only: Off',
|
||||||
|
filterAlbumArtistContributors
|
||||||
|
? 'Album Artist metadata: Primary only'
|
||||||
|
: 'Album Artist metadata: Full',
|
||||||
|
];
|
||||||
|
return statuses.join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
String _getLyricsModeLabel(BuildContext context, String mode) {
|
String _getLyricsModeLabel(BuildContext context, String mode) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'external':
|
case 'external':
|
||||||
@@ -1354,6 +1478,7 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
final isExtensionService = ![
|
final isExtensionService = ![
|
||||||
'tidal',
|
'tidal',
|
||||||
'qobuz',
|
'qobuz',
|
||||||
|
'amazon',
|
||||||
].contains(currentService);
|
].contains(currentService);
|
||||||
final isCurrentExtensionEnabled = isExtensionService
|
final isCurrentExtensionEnabled = isExtensionService
|
||||||
? extensionProviders.any((e) => e.id == currentService)
|
? extensionProviders.any((e) => e.id == currentService)
|
||||||
@@ -1380,6 +1505,13 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
isSelected: effectiveService == 'qobuz',
|
isSelected: effectiveService == 'qobuz',
|
||||||
onTap: () => onChanged('qobuz'),
|
onTap: () => onChanged('qobuz'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(
|
||||||
|
icon: Icons.shopping_bag_outlined,
|
||||||
|
label: 'Amazon',
|
||||||
|
isSelected: effectiveService == 'amazon',
|
||||||
|
onTap: () => onChanged('amazon'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (extensionProviders.isNotEmpty) ...[
|
if (extensionProviders.isNotEmpty) ...[
|
||||||
@@ -1436,9 +1568,7 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Material(
|
child: Material(
|
||||||
color: isSelected
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
? colorScheme.primaryContainer
|
|
||||||
: unselectedColor,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
|||||||
@@ -68,10 +68,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
Future<void> _checkInitialPermissions() async {
|
Future<void> _checkInitialPermissions() async {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
|
final notificationStatus = await Permission.notification.status;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_storagePermissionGranted = true;
|
_storagePermissionGranted = true;
|
||||||
_notificationPermissionGranted = true;
|
_notificationPermissionGranted =
|
||||||
|
notificationStatus.isGranted || notificationStatus.isProvisional;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (Platform.isAndroid) {
|
} else if (Platform.isAndroid) {
|
||||||
@@ -181,7 +183,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Future<void> _requestNotificationPermission() async {
|
Future<void> _requestNotificationPermission() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
if (_androidSdkVersion >= 33) {
|
if (Platform.isIOS) {
|
||||||
|
final status = await Permission.notification.request();
|
||||||
|
if (status.isGranted || status.isProvisional) {
|
||||||
|
setState(() => _notificationPermissionGranted = true);
|
||||||
|
} else if (status.isPermanentlyDenied) {
|
||||||
|
await _showPermissionDeniedDialog('Notification');
|
||||||
|
}
|
||||||
|
} else if (_androidSdkVersion >= 33) {
|
||||||
final status = await Permission.notification.request();
|
final status = await Permission.notification.request();
|
||||||
if (status.isGranted) {
|
if (status.isGranted) {
|
||||||
setState(() => _notificationPermissionGranted = true);
|
setState(() => _notificationPermissionGranted = true);
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
class DownloadRequestPayload {
|
||||||
|
final String isrc;
|
||||||
|
final String service;
|
||||||
|
final String spotifyId;
|
||||||
|
final String trackName;
|
||||||
|
final String artistName;
|
||||||
|
final String albumName;
|
||||||
|
final String albumArtist;
|
||||||
|
final String coverUrl;
|
||||||
|
final String outputDir;
|
||||||
|
final String filenameFormat;
|
||||||
|
final String quality;
|
||||||
|
final bool embedLyrics;
|
||||||
|
final bool embedMaxQualityCover;
|
||||||
|
final int trackNumber;
|
||||||
|
final int discNumber;
|
||||||
|
final int totalTracks;
|
||||||
|
final String releaseDate;
|
||||||
|
final String itemId;
|
||||||
|
final int durationMs;
|
||||||
|
final String source;
|
||||||
|
final String genre;
|
||||||
|
final String label;
|
||||||
|
final String copyright;
|
||||||
|
final String tidalId;
|
||||||
|
final String qobuzId;
|
||||||
|
final String deezerId;
|
||||||
|
final String lyricsMode;
|
||||||
|
final bool useExtensions;
|
||||||
|
final bool useFallback;
|
||||||
|
final String storageMode;
|
||||||
|
final String safTreeUri;
|
||||||
|
final String safRelativeDir;
|
||||||
|
final String safFileName;
|
||||||
|
final String safOutputExt;
|
||||||
|
|
||||||
|
const DownloadRequestPayload({
|
||||||
|
this.isrc = '',
|
||||||
|
this.service = '',
|
||||||
|
this.spotifyId = '',
|
||||||
|
required this.trackName,
|
||||||
|
required this.artistName,
|
||||||
|
required this.albumName,
|
||||||
|
this.albumArtist = '',
|
||||||
|
this.coverUrl = '',
|
||||||
|
required this.outputDir,
|
||||||
|
required this.filenameFormat,
|
||||||
|
this.quality = 'LOSSLESS',
|
||||||
|
this.embedLyrics = true,
|
||||||
|
this.embedMaxQualityCover = true,
|
||||||
|
this.trackNumber = 1,
|
||||||
|
this.discNumber = 1,
|
||||||
|
this.totalTracks = 1,
|
||||||
|
this.releaseDate = '',
|
||||||
|
this.itemId = '',
|
||||||
|
this.durationMs = 0,
|
||||||
|
this.source = '',
|
||||||
|
this.genre = '',
|
||||||
|
this.label = '',
|
||||||
|
this.copyright = '',
|
||||||
|
this.tidalId = '',
|
||||||
|
this.qobuzId = '',
|
||||||
|
this.deezerId = '',
|
||||||
|
this.lyricsMode = 'embed',
|
||||||
|
this.useExtensions = false,
|
||||||
|
this.useFallback = false,
|
||||||
|
this.storageMode = 'app',
|
||||||
|
this.safTreeUri = '',
|
||||||
|
this.safRelativeDir = '',
|
||||||
|
this.safFileName = '',
|
||||||
|
this.safOutputExt = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'isrc': isrc,
|
||||||
|
'service': service,
|
||||||
|
'spotify_id': spotifyId,
|
||||||
|
'track_name': trackName,
|
||||||
|
'artist_name': artistName,
|
||||||
|
'album_name': albumName,
|
||||||
|
'album_artist': albumArtist,
|
||||||
|
'cover_url': coverUrl,
|
||||||
|
'output_dir': outputDir,
|
||||||
|
'filename_format': filenameFormat,
|
||||||
|
'quality': quality,
|
||||||
|
'embed_lyrics': embedLyrics,
|
||||||
|
'embed_max_quality_cover': embedMaxQualityCover,
|
||||||
|
'track_number': trackNumber,
|
||||||
|
'disc_number': discNumber,
|
||||||
|
'total_tracks': totalTracks,
|
||||||
|
'release_date': releaseDate,
|
||||||
|
'item_id': itemId,
|
||||||
|
'duration_ms': durationMs,
|
||||||
|
'source': source,
|
||||||
|
'genre': genre,
|
||||||
|
'label': label,
|
||||||
|
'copyright': copyright,
|
||||||
|
'tidal_id': tidalId,
|
||||||
|
'qobuz_id': qobuzId,
|
||||||
|
'deezer_id': deezerId,
|
||||||
|
'lyrics_mode': lyricsMode,
|
||||||
|
'use_extensions': useExtensions,
|
||||||
|
'use_fallback': useFallback,
|
||||||
|
'storage_mode': storageMode,
|
||||||
|
'saf_tree_uri': safTreeUri,
|
||||||
|
'saf_relative_dir': safRelativeDir,
|
||||||
|
'saf_file_name': safFileName,
|
||||||
|
'saf_output_ext': safOutputExt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
DownloadRequestPayload withStrategy({
|
||||||
|
bool? useExtensions,
|
||||||
|
bool? useFallback,
|
||||||
|
}) {
|
||||||
|
return DownloadRequestPayload(
|
||||||
|
isrc: isrc,
|
||||||
|
service: service,
|
||||||
|
spotifyId: spotifyId,
|
||||||
|
trackName: trackName,
|
||||||
|
artistName: artistName,
|
||||||
|
albumName: albumName,
|
||||||
|
albumArtist: albumArtist,
|
||||||
|
coverUrl: coverUrl,
|
||||||
|
outputDir: outputDir,
|
||||||
|
filenameFormat: filenameFormat,
|
||||||
|
quality: quality,
|
||||||
|
embedLyrics: embedLyrics,
|
||||||
|
embedMaxQualityCover: embedMaxQualityCover,
|
||||||
|
trackNumber: trackNumber,
|
||||||
|
discNumber: discNumber,
|
||||||
|
totalTracks: totalTracks,
|
||||||
|
releaseDate: releaseDate,
|
||||||
|
itemId: itemId,
|
||||||
|
durationMs: durationMs,
|
||||||
|
source: source,
|
||||||
|
genre: genre,
|
||||||
|
label: label,
|
||||||
|
copyright: copyright,
|
||||||
|
tidalId: tidalId,
|
||||||
|
qobuzId: qobuzId,
|
||||||
|
deezerId: deezerId,
|
||||||
|
lyricsMode: lyricsMode,
|
||||||
|
useExtensions: useExtensions ?? this.useExtensions,
|
||||||
|
useFallback: useFallback ?? this.useFallback,
|
||||||
|
storageMode: storageMode,
|
||||||
|
safTreeUri: safTreeUri,
|
||||||
|
safRelativeDir: safRelativeDir,
|
||||||
|
safFileName: safFileName,
|
||||||
|
safOutputExt: safOutputExt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
|
|
||||||
|
class _EmbeddedCoverCacheEntry {
|
||||||
|
final String previewPath;
|
||||||
|
final int? sourceModTimeMillis;
|
||||||
|
|
||||||
|
const _EmbeddedCoverCacheEntry({
|
||||||
|
required this.previewPath,
|
||||||
|
this.sourceModTimeMillis,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared resolver for embedded cover previews from downloaded/local files.
|
||||||
|
/// It keeps a bounded in-memory cache and only refreshes extraction
|
||||||
|
/// when the source file changed.
|
||||||
|
class DownloadedEmbeddedCoverResolver {
|
||||||
|
static const int _maxCacheEntries = 180;
|
||||||
|
|
||||||
|
static final LinkedHashMap<String, _EmbeddedCoverCacheEntry> _cache =
|
||||||
|
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
|
||||||
|
static final Set<String> _pendingExtract = <String>{};
|
||||||
|
static final Set<String> _pendingRefresh = <String>{};
|
||||||
|
static final Set<String> _failedExtract = <String>{};
|
||||||
|
|
||||||
|
static String cleanFilePath(String? filePath) {
|
||||||
|
if (filePath == null) return '';
|
||||||
|
if (filePath.startsWith('EXISTS:')) {
|
||||||
|
return filePath.substring(7);
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<int?> readFileModTimeMillis(String? filePath) async {
|
||||||
|
final cleanPath = cleanFilePath(filePath);
|
||||||
|
if (cleanPath.isEmpty) return null;
|
||||||
|
|
||||||
|
if (isContentUri(cleanPath)) {
|
||||||
|
try {
|
||||||
|
final modTimes = await PlatformBridge.getSafFileModTimes([cleanPath]);
|
||||||
|
return modTimes[cleanPath];
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final stat = await File(cleanPath).stat();
|
||||||
|
return stat.modified.millisecondsSinceEpoch;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? resolve(String? filePath, {VoidCallback? onChanged}) {
|
||||||
|
final cleanPath = cleanFilePath(filePath);
|
||||||
|
if (cleanPath.isEmpty) return null;
|
||||||
|
|
||||||
|
if (_pendingRefresh.remove(cleanPath)) {
|
||||||
|
_ensureCover(cleanPath, forceRefresh: true, onChanged: onChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cached = _cache[cleanPath];
|
||||||
|
if (cached != null) {
|
||||||
|
if (File(cached.previewPath).existsSync()) {
|
||||||
|
_touch(cleanPath, cached);
|
||||||
|
return cached.previewPath;
|
||||||
|
}
|
||||||
|
_cache.remove(cleanPath);
|
||||||
|
_cleanupTempCoverPathSync(cached.previewPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> scheduleRefreshForPath(
|
||||||
|
String? filePath, {
|
||||||
|
int? beforeModTime,
|
||||||
|
bool force = false,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) async {
|
||||||
|
final cleanPath = cleanFilePath(filePath);
|
||||||
|
if (cleanPath.isEmpty) return;
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
if (beforeModTime == null) return;
|
||||||
|
final afterModTime = await readFileModTimeMillis(cleanPath);
|
||||||
|
if (afterModTime != null && afterModTime == beforeModTime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingRefresh.add(cleanPath);
|
||||||
|
_failedExtract.remove(cleanPath);
|
||||||
|
onChanged?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void invalidate(String? filePath) {
|
||||||
|
final cleanPath = cleanFilePath(filePath);
|
||||||
|
if (cleanPath.isEmpty) return;
|
||||||
|
|
||||||
|
final cached = _cache.remove(cleanPath);
|
||||||
|
_pendingExtract.remove(cleanPath);
|
||||||
|
_pendingRefresh.remove(cleanPath);
|
||||||
|
_failedExtract.remove(cleanPath);
|
||||||
|
if (cached != null) {
|
||||||
|
_cleanupTempCoverPathSync(cached.previewPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void invalidatePathsNotIn(Set<String> validCleanPaths) {
|
||||||
|
if (validCleanPaths.isEmpty) {
|
||||||
|
final keys = _cache.keys.toList(growable: false);
|
||||||
|
for (final key in keys) {
|
||||||
|
invalidate(key);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final staleKeys = _cache.keys
|
||||||
|
.where((path) => !validCleanPaths.contains(path))
|
||||||
|
.toList(growable: false);
|
||||||
|
for (final key in staleKeys) {
|
||||||
|
invalidate(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _touch(String cleanPath, _EmbeddedCoverCacheEntry entry) {
|
||||||
|
_cache
|
||||||
|
..remove(cleanPath)
|
||||||
|
..[cleanPath] = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _trimCacheIfNeeded() {
|
||||||
|
while (_cache.length > _maxCacheEntries) {
|
||||||
|
final oldestKey = _cache.keys.first;
|
||||||
|
final removed = _cache.remove(oldestKey);
|
||||||
|
if (removed != null) {
|
||||||
|
_cleanupTempCoverPathSync(removed.previewPath);
|
||||||
|
}
|
||||||
|
_pendingExtract.remove(oldestKey);
|
||||||
|
_pendingRefresh.remove(oldestKey);
|
||||||
|
_failedExtract.remove(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _ensureCover(
|
||||||
|
String cleanPath, {
|
||||||
|
bool forceRefresh = false,
|
||||||
|
int? knownModTime,
|
||||||
|
VoidCallback? onChanged,
|
||||||
|
}) {
|
||||||
|
if (cleanPath.isEmpty) return;
|
||||||
|
if (_pendingExtract.contains(cleanPath)) return;
|
||||||
|
if (!forceRefresh && _cache.containsKey(cleanPath)) return;
|
||||||
|
if (!forceRefresh && _failedExtract.contains(cleanPath)) return;
|
||||||
|
|
||||||
|
_pendingExtract.add(cleanPath);
|
||||||
|
Future.microtask(() async {
|
||||||
|
String? outputPath;
|
||||||
|
try {
|
||||||
|
final modTime = knownModTime ?? await readFileModTimeMillis(cleanPath);
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp(
|
||||||
|
'download_cover_preview_',
|
||||||
|
);
|
||||||
|
outputPath =
|
||||||
|
'${tempDir.path}${Platform.pathSeparator}cover_preview.jpg';
|
||||||
|
final result = await PlatformBridge.extractCoverToFile(
|
||||||
|
cleanPath,
|
||||||
|
outputPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
final hasCover =
|
||||||
|
result['error'] == null && await File(outputPath).exists();
|
||||||
|
if (!hasCover) {
|
||||||
|
_failedExtract.add(cleanPath);
|
||||||
|
_cleanupTempCoverPathSync(outputPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final previous = _cache[cleanPath];
|
||||||
|
final next = _EmbeddedCoverCacheEntry(
|
||||||
|
previewPath: outputPath,
|
||||||
|
sourceModTimeMillis: modTime,
|
||||||
|
);
|
||||||
|
_touch(cleanPath, next);
|
||||||
|
_failedExtract.remove(cleanPath);
|
||||||
|
_trimCacheIfNeeded();
|
||||||
|
|
||||||
|
if (previous != null && previous.previewPath != outputPath) {
|
||||||
|
_cleanupTempCoverPathSync(previous.previewPath);
|
||||||
|
}
|
||||||
|
onChanged?.call();
|
||||||
|
} catch (_) {
|
||||||
|
_failedExtract.add(cleanPath);
|
||||||
|
_cleanupTempCoverPathSync(outputPath);
|
||||||
|
} finally {
|
||||||
|
_pendingExtract.remove(cleanPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _cleanupTempCoverPathSync(String? coverPath) {
|
||||||
|
if (coverPath == null || coverPath.isEmpty) return;
|
||||||
|
try {
|
||||||
|
final file = File(coverPath);
|
||||||
|
if (file.existsSync()) {
|
||||||
|
file.deleteSync();
|
||||||
|
}
|
||||||
|
final parent = file.parent;
|
||||||
|
if (parent.existsSync()) {
|
||||||
|
parent.deleteSync(recursive: true);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,48 @@ class FFmpegService {
|
|||||||
return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt';
|
return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<String> _buildDecryptionKeyCandidates(String rawKey) {
|
||||||
|
final candidates = <String>[];
|
||||||
|
|
||||||
|
void addCandidate(String key) {
|
||||||
|
final normalized = key.trim();
|
||||||
|
if (normalized.isEmpty) return;
|
||||||
|
if (!candidates.contains(normalized)) {
|
||||||
|
candidates.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final trimmed = rawKey.trim();
|
||||||
|
if (trimmed.isEmpty) return candidates;
|
||||||
|
|
||||||
|
addCandidate(trimmed);
|
||||||
|
|
||||||
|
final noPrefix = trimmed.startsWith(RegExp(r'0x', caseSensitive: false))
|
||||||
|
? trimmed.substring(2)
|
||||||
|
: trimmed;
|
||||||
|
addCandidate(noPrefix);
|
||||||
|
|
||||||
|
final compactHex = noPrefix.replaceAll(RegExp(r'[^0-9a-fA-F]'), '');
|
||||||
|
if (compactHex.isNotEmpty && compactHex.length.isEven) {
|
||||||
|
addCandidate(compactHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final b64 = noPrefix.replaceAll(RegExp(r'\s+'), '');
|
||||||
|
final decoded = base64Decode(b64);
|
||||||
|
if (decoded.isNotEmpty) {
|
||||||
|
final hex = decoded
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
if (hex.isNotEmpty) {
|
||||||
|
addCandidate(hex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<FFmpegResult> _execute(String command) async {
|
static Future<FFmpegResult> _execute(String command) async {
|
||||||
try {
|
try {
|
||||||
final session = await FFmpegKit.execute(command);
|
final session = await FFmpegKit.execute(command);
|
||||||
@@ -77,7 +119,7 @@ class FFmpegService {
|
|||||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||||
|
|
||||||
final command =
|
final command =
|
||||||
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
'-v error -xerror -i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||||
|
|
||||||
final result = await _execute(command);
|
final result = await _execute(command);
|
||||||
|
|
||||||
@@ -133,6 +175,111 @@ class FFmpegService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<String?> decryptAudioFile({
|
||||||
|
required String inputPath,
|
||||||
|
required String decryptionKey,
|
||||||
|
bool deleteOriginal = true,
|
||||||
|
}) async {
|
||||||
|
final trimmedKey = decryptionKey.trim();
|
||||||
|
if (trimmedKey.isEmpty) return inputPath;
|
||||||
|
|
||||||
|
// Amazon encrypted streams are commonly MP4 container with FLAC audio.
|
||||||
|
// Prefer FLAC output to avoid MP4 muxing errors during decrypt copy.
|
||||||
|
final preferredExt = inputPath.toLowerCase().endsWith('.m4a')
|
||||||
|
? '.flac'
|
||||||
|
: inputPath.toLowerCase().endsWith('.flac')
|
||||||
|
? '.flac'
|
||||||
|
: inputPath.toLowerCase().endsWith('.mp3')
|
||||||
|
? '.mp3'
|
||||||
|
: inputPath.toLowerCase().endsWith('.opus')
|
||||||
|
? '.opus'
|
||||||
|
: '.flac';
|
||||||
|
var tempOutput = _buildOutputPath(inputPath, preferredExt);
|
||||||
|
|
||||||
|
String buildDecryptCommand(
|
||||||
|
String outputPath, {
|
||||||
|
required bool mapAudioOnly,
|
||||||
|
required String key,
|
||||||
|
}) {
|
||||||
|
final audioMap = mapAudioOnly ? '-map 0:a ' : '';
|
||||||
|
return '-v error -decryption_key "$key" -i "$inputPath" $audioMap-c copy "$outputPath" -y';
|
||||||
|
}
|
||||||
|
|
||||||
|
final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey);
|
||||||
|
if (keyCandidates.isEmpty) {
|
||||||
|
_log.e('No usable decryption key candidates');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
FFmpegResult? lastResult;
|
||||||
|
var decryptSucceeded = false;
|
||||||
|
|
||||||
|
for (final keyCandidate in keyCandidates) {
|
||||||
|
_log.d(
|
||||||
|
'Executing FFmpeg decrypt command (key length: ${keyCandidate.length})',
|
||||||
|
);
|
||||||
|
var result = await _execute(
|
||||||
|
buildDecryptCommand(
|
||||||
|
tempOutput,
|
||||||
|
mapAudioOnly: preferredExt == '.flac',
|
||||||
|
key: keyCandidate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback for uncommon streams that cannot be remuxed into FLAC.
|
||||||
|
if (!result.success && preferredExt == '.flac') {
|
||||||
|
final fallbackOutput = _buildOutputPath(inputPath, '.m4a');
|
||||||
|
final fallbackResult = await _execute(
|
||||||
|
buildDecryptCommand(
|
||||||
|
fallbackOutput,
|
||||||
|
mapAudioOnly: false,
|
||||||
|
key: keyCandidate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (fallbackResult.success) {
|
||||||
|
tempOutput = fallbackOutput;
|
||||||
|
result = fallbackResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
decryptSucceeded = true;
|
||||||
|
lastResult = result;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
lastResult = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decryptSucceeded) {
|
||||||
|
_log.e('FFmpeg decrypt failed: ${lastResult?.output ?? 'unknown error'}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
final inputFile = File(inputPath);
|
||||||
|
if (!await tempFile.exists()) {
|
||||||
|
_log.e('Decrypted output file not found: $tempOutput');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteOriginal && await inputFile.exists()) {
|
||||||
|
await inputFile.delete();
|
||||||
|
}
|
||||||
|
return tempOutput;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to finalize decrypted file: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<String?> convertFlacToMp3(
|
static Future<String?> convertFlacToMp3(
|
||||||
String inputPath, {
|
String inputPath, {
|
||||||
String bitrate = '320k',
|
String bitrate = '320k',
|
||||||
@@ -616,6 +763,97 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unified audio format conversion with full metadata + cover preservation.
|
||||||
|
/// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format).
|
||||||
|
/// Returns the new file path on success, null on failure.
|
||||||
|
static Future<String?> convertAudioFormat({
|
||||||
|
required String inputPath,
|
||||||
|
required String targetFormat,
|
||||||
|
required String bitrate,
|
||||||
|
required Map<String, String> metadata,
|
||||||
|
String? coverPath,
|
||||||
|
bool deleteOriginal = true,
|
||||||
|
}) async {
|
||||||
|
final format = targetFormat.toLowerCase();
|
||||||
|
if (format != 'mp3' && format != 'opus') {
|
||||||
|
_log.e('Unsupported target format: $targetFormat');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final extension = format == 'opus' ? '.opus' : '.mp3';
|
||||||
|
final outputPath = _buildOutputPath(inputPath, extension);
|
||||||
|
|
||||||
|
// Step 1: Convert audio
|
||||||
|
String command;
|
||||||
|
if (format == 'opus') {
|
||||||
|
command =
|
||||||
|
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
|
||||||
|
} else {
|
||||||
|
command =
|
||||||
|
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 "$outputPath" -y';
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i(
|
||||||
|
'Converting ${inputPath.split(Platform.pathSeparator).last} to $format @ $bitrate',
|
||||||
|
);
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
_log.e('Audio conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Embed metadata + cover into the converted file.
|
||||||
|
// Treat embed failure as conversion failure when metadata/cover was requested.
|
||||||
|
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
|
||||||
|
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
|
||||||
|
if (hasMetadata || hasCover) {
|
||||||
|
String? embedResult;
|
||||||
|
if (format == 'mp3') {
|
||||||
|
embedResult = await embedMetadataToMp3(
|
||||||
|
mp3Path: outputPath,
|
||||||
|
coverPath: coverPath,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
embedResult = await embedMetadataToOpus(
|
||||||
|
opusPath: outputPath,
|
||||||
|
coverPath: coverPath,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedResult == null) {
|
||||||
|
_log.e(
|
||||||
|
'Metadata/Cover preservation failed, rolling back converted file',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final out = File(outputPath);
|
||||||
|
if (await out.exists()) {
|
||||||
|
await out.delete();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to cleanup failed converted file: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Delete original if requested
|
||||||
|
if (deleteOriginal) {
|
||||||
|
try {
|
||||||
|
await File(inputPath).delete();
|
||||||
|
_log.i(
|
||||||
|
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to delete original: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
static Map<String, String> _convertToId3Tags(
|
static Map<String, String> _convertToId3Tags(
|
||||||
Map<String, String> vorbisMetadata,
|
Map<String, String> vorbisMetadata,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -77,8 +77,12 @@ class HistoryDatabase {
|
|||||||
// Indexes for fast lookups
|
// Indexes for fast lookups
|
||||||
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
|
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
|
||||||
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
|
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
|
||||||
await db.execute('CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)');
|
await db.execute(
|
||||||
await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)');
|
'CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_album ON history(album_name, album_artist)',
|
||||||
|
);
|
||||||
|
|
||||||
_log.i('Database schema created with indexes');
|
_log.i('Database schema created with indexes');
|
||||||
}
|
}
|
||||||
@@ -131,7 +135,10 @@ class HistoryDatabase {
|
|||||||
|
|
||||||
// Check if path contains an iOS container path
|
// Check if path contains an iOS container path
|
||||||
if (_iosContainerPattern.hasMatch(filePath)) {
|
if (_iosContainerPattern.hasMatch(filePath)) {
|
||||||
final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!);
|
final normalized = filePath.replaceFirst(
|
||||||
|
_iosContainerPattern,
|
||||||
|
_currentContainerPath!,
|
||||||
|
);
|
||||||
if (normalized != filePath) {
|
if (normalized != filePath) {
|
||||||
_log.d('Normalized iOS path: $filePath -> $normalized');
|
_log.d('Normalized iOS path: $filePath -> $normalized');
|
||||||
}
|
}
|
||||||
@@ -220,7 +227,9 @@ class HistoryDatabase {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||||
_log.i('Migrating ${jsonList.length} items from SharedPreferences to SQLite');
|
_log.i(
|
||||||
|
'Migrating ${jsonList.length} items from SharedPreferences to SQLite',
|
||||||
|
);
|
||||||
|
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final batch = db.batch();
|
final batch = db.batch();
|
||||||
@@ -389,7 +398,7 @@ class HistoryDatabase {
|
|||||||
Future<Set<String>> getAllSpotifyIds() async {
|
Future<Set<String>> getAllSpotifyIds() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final rows = await db.rawQuery(
|
final rows = await db.rawQuery(
|
||||||
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""'
|
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""',
|
||||||
);
|
);
|
||||||
return rows.map((r) => r['spotify_id'] as String).toSet();
|
return rows.map((r) => r['spotify_id'] as String).toSet();
|
||||||
}
|
}
|
||||||
@@ -450,19 +459,51 @@ class HistoryDatabase {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close database
|
/// Close database
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.close();
|
await db.close();
|
||||||
_database = null;
|
_database = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update file path for a history entry (e.g. after format conversion)
|
||||||
|
Future<void> updateFilePath(
|
||||||
|
String id,
|
||||||
|
String newFilePath, {
|
||||||
|
String? newSafFileName,
|
||||||
|
String? newQuality,
|
||||||
|
int? newBitDepth,
|
||||||
|
int? newSampleRate,
|
||||||
|
bool clearAudioSpecs = false,
|
||||||
|
}) async {
|
||||||
|
final db = await database;
|
||||||
|
final values = <String, dynamic>{'file_path': newFilePath};
|
||||||
|
if (newSafFileName != null) {
|
||||||
|
values['saf_file_name'] = newSafFileName;
|
||||||
|
}
|
||||||
|
if (newQuality != null) {
|
||||||
|
values['quality'] = newQuality;
|
||||||
|
}
|
||||||
|
if (clearAudioSpecs) {
|
||||||
|
values['bit_depth'] = null;
|
||||||
|
values['sample_rate'] = null;
|
||||||
|
} else {
|
||||||
|
if (newBitDepth != null) {
|
||||||
|
values['bit_depth'] = newBitDepth;
|
||||||
|
}
|
||||||
|
if (newSampleRate != null) {
|
||||||
|
values['sample_rate'] = newSampleRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all file paths from download history
|
/// Get all file paths from download history
|
||||||
/// Used to exclude downloaded files from local library scan
|
/// Used to exclude downloaded files from local library scan
|
||||||
Future<Set<String>> getAllFilePaths() async {
|
Future<Set<String>> getAllFilePaths() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final rows = await db.rawQuery(
|
final rows = await db.rawQuery(
|
||||||
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""'
|
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""',
|
||||||
);
|
);
|
||||||
return rows.map((r) => r['file_path'] as String).toSet();
|
return rows.map((r) => r['file_path'] as String).toSet();
|
||||||
}
|
}
|
||||||
@@ -484,12 +525,18 @@ class HistoryDatabase {
|
|||||||
if (ids.isEmpty) return 0;
|
if (ids.isEmpty) return 0;
|
||||||
|
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final placeholders = List.filled(ids.length, '?').join(',');
|
var totalDeleted = 0;
|
||||||
final count = await db.rawDelete(
|
const chunkSize = 500;
|
||||||
'DELETE FROM history WHERE id IN ($placeholders)',
|
for (var i = 0; i < ids.length; i += chunkSize) {
|
||||||
ids,
|
final end = (i + chunkSize < ids.length) ? i + chunkSize : ids.length;
|
||||||
);
|
final chunk = ids.sublist(i, end);
|
||||||
_log.i('Deleted $count orphaned entries');
|
final placeholders = List.filled(chunk.length, '?').join(',');
|
||||||
return count;
|
totalDeleted += await db.rawDelete(
|
||||||
|
'DELETE FROM history WHERE id IN ($placeholders)',
|
||||||
|
chunk,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_log.i('Deleted $totalDeleted orphaned entries');
|
||||||
|
return totalDeleted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class LocalLibraryItem {
|
|||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
final int? bitDepth;
|
final int? bitDepth;
|
||||||
final int? sampleRate;
|
final int? sampleRate;
|
||||||
|
final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg)
|
||||||
final String? genre;
|
final String? genre;
|
||||||
final String? format; // flac, mp3, opus, m4a
|
final String? format; // flac, mp3, opus, m4a
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ class LocalLibraryItem {
|
|||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
this.bitDepth,
|
this.bitDepth,
|
||||||
this.sampleRate,
|
this.sampleRate,
|
||||||
|
this.bitrate,
|
||||||
this.genre,
|
this.genre,
|
||||||
this.format,
|
this.format,
|
||||||
});
|
});
|
||||||
@@ -64,6 +66,7 @@ class LocalLibraryItem {
|
|||||||
'releaseDate': releaseDate,
|
'releaseDate': releaseDate,
|
||||||
'bitDepth': bitDepth,
|
'bitDepth': bitDepth,
|
||||||
'sampleRate': sampleRate,
|
'sampleRate': sampleRate,
|
||||||
|
'bitrate': bitrate,
|
||||||
'genre': genre,
|
'genre': genre,
|
||||||
'format': format,
|
'format': format,
|
||||||
};
|
};
|
||||||
@@ -86,6 +89,7 @@ class LocalLibraryItem {
|
|||||||
releaseDate: json['releaseDate'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
bitDepth: json['bitDepth'] as int?,
|
bitDepth: json['bitDepth'] as int?,
|
||||||
sampleRate: json['sampleRate'] as int?,
|
sampleRate: json['sampleRate'] as int?,
|
||||||
|
bitrate: (json['bitrate'] as num?)?.toInt(),
|
||||||
genre: json['genre'] as String?,
|
genre: json['genre'] as String?,
|
||||||
format: json['format'] as String?,
|
format: json['format'] as String?,
|
||||||
);
|
);
|
||||||
@@ -115,7 +119,7 @@ class LibraryDatabase {
|
|||||||
|
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 3, // Bumped version for file_mod_time migration
|
version: 4, // Bumped version for bitrate column
|
||||||
onCreate: _createDB,
|
onCreate: _createDB,
|
||||||
onUpgrade: _upgradeDB,
|
onUpgrade: _upgradeDB,
|
||||||
);
|
);
|
||||||
@@ -142,6 +146,7 @@ class LibraryDatabase {
|
|||||||
release_date TEXT,
|
release_date TEXT,
|
||||||
bit_depth INTEGER,
|
bit_depth INTEGER,
|
||||||
sample_rate INTEGER,
|
sample_rate INTEGER,
|
||||||
|
bitrate INTEGER,
|
||||||
genre TEXT,
|
genre TEXT,
|
||||||
format TEXT
|
format TEXT
|
||||||
)
|
)
|
||||||
@@ -169,6 +174,12 @@ class LibraryDatabase {
|
|||||||
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
|
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
|
||||||
_log.i('Added file_mod_time column for incremental scanning');
|
_log.i('Added file_mod_time column for incremental scanning');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 4) {
|
||||||
|
// Add bitrate column for lossy format quality info
|
||||||
|
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
|
||||||
|
_log.i('Added bitrate column for lossy format quality');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||||
@@ -189,6 +200,7 @@ class LibraryDatabase {
|
|||||||
'release_date': json['releaseDate'],
|
'release_date': json['releaseDate'],
|
||||||
'bit_depth': json['bitDepth'],
|
'bit_depth': json['bitDepth'],
|
||||||
'sample_rate': json['sampleRate'],
|
'sample_rate': json['sampleRate'],
|
||||||
|
'bitrate': json['bitrate'],
|
||||||
'genre': json['genre'],
|
'genre': json['genre'],
|
||||||
'format': json['format'],
|
'format': json['format'],
|
||||||
};
|
};
|
||||||
@@ -212,6 +224,7 @@ class LibraryDatabase {
|
|||||||
'releaseDate': row['release_date'],
|
'releaseDate': row['release_date'],
|
||||||
'bitDepth': row['bit_depth'],
|
'bitDepth': row['bit_depth'],
|
||||||
'sampleRate': row['sample_rate'],
|
'sampleRate': row['sample_rate'],
|
||||||
|
'bitrate': row['bitrate'],
|
||||||
'genre': row['genre'],
|
'genre': row['genre'],
|
||||||
'format': row['format'],
|
'format': row['format'],
|
||||||
};
|
};
|
||||||
@@ -229,6 +242,7 @@ class LibraryDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||||
|
if (items.isEmpty) return;
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final batch = db.batch();
|
final batch = db.batch();
|
||||||
|
|
||||||
@@ -351,15 +365,45 @@ class LibraryDatabase {
|
|||||||
final db = await database;
|
final db = await database;
|
||||||
final rows = await db.query('library', columns: ['id', 'file_path']);
|
final rows = await db.query('library', columns: ['id', 'file_path']);
|
||||||
|
|
||||||
int removed = 0;
|
final missingIds = <String>[];
|
||||||
for (final row in rows) {
|
const checkChunkSize = 16;
|
||||||
final filePath = row['file_path'] as String;
|
for (var i = 0; i < rows.length; i += checkChunkSize) {
|
||||||
if (!await fileExists(filePath)) {
|
final end = (i + checkChunkSize < rows.length)
|
||||||
await db.delete('library', where: 'id = ?', whereArgs: [row['id']]);
|
? i + checkChunkSize
|
||||||
removed++;
|
: rows.length;
|
||||||
|
final chunk = rows.sublist(i, end);
|
||||||
|
final checks = await Future.wait<MapEntry<String, bool>>(
|
||||||
|
chunk.map((row) async {
|
||||||
|
final id = row['id'] as String;
|
||||||
|
final filePath = row['file_path'] as String;
|
||||||
|
return MapEntry(id, await fileExists(filePath));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (final check in checks) {
|
||||||
|
if (!check.value) {
|
||||||
|
missingIds.add(check.key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (missingIds.isEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var removed = 0;
|
||||||
|
const deleteChunkSize = 500;
|
||||||
|
for (var i = 0; i < missingIds.length; i += deleteChunkSize) {
|
||||||
|
final end = (i + deleteChunkSize < missingIds.length)
|
||||||
|
? i + deleteChunkSize
|
||||||
|
: missingIds.length;
|
||||||
|
final idChunk = missingIds.sublist(i, end);
|
||||||
|
final placeholders = List.filled(idChunk.length, '?').join(',');
|
||||||
|
removed += await db.rawDelete(
|
||||||
|
'DELETE FROM library WHERE id IN ($placeholders)',
|
||||||
|
idChunk,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (removed > 0) {
|
if (removed > 0) {
|
||||||
_log.i('Cleaned up $removed missing files from library');
|
_log.i('Cleaned up $removed missing files from library');
|
||||||
}
|
}
|
||||||
@@ -440,14 +484,22 @@ class LibraryDatabase {
|
|||||||
Future<int> deleteByPaths(List<String> filePaths) async {
|
Future<int> deleteByPaths(List<String> filePaths) async {
|
||||||
if (filePaths.isEmpty) return 0;
|
if (filePaths.isEmpty) return 0;
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final placeholders = List.filled(filePaths.length, '?').join(',');
|
var totalDeleted = 0;
|
||||||
final result = await db.rawDelete(
|
const chunkSize = 500;
|
||||||
'DELETE FROM library WHERE file_path IN ($placeholders)',
|
for (var i = 0; i < filePaths.length; i += chunkSize) {
|
||||||
filePaths,
|
final end = (i + chunkSize < filePaths.length)
|
||||||
);
|
? i + chunkSize
|
||||||
if (result > 0) {
|
: filePaths.length;
|
||||||
_log.i('Deleted $result items from library');
|
final chunk = filePaths.sublist(i, end);
|
||||||
|
final placeholders = List.filled(chunk.length, '?').join(',');
|
||||||
|
totalDeleted += await db.rawDelete(
|
||||||
|
'DELETE FROM library WHERE file_path IN ($placeholders)',
|
||||||
|
chunk,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return result;
|
if (totalDeleted > 0) {
|
||||||
|
_log.i('Deleted $totalDeleted items from library');
|
||||||
|
}
|
||||||
|
return totalDeleted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,39 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
class NotificationService {
|
class NotificationService {
|
||||||
static final NotificationService _instance = NotificationService._internal();
|
static final NotificationService _instance = NotificationService._internal();
|
||||||
factory NotificationService() => _instance;
|
factory NotificationService() => _instance;
|
||||||
NotificationService._internal();
|
NotificationService._internal();
|
||||||
|
|
||||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
final FlutterLocalNotificationsPlugin _notifications =
|
||||||
|
FlutterLocalNotificationsPlugin();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
bool _notificationPermissionRequested = false;
|
||||||
|
|
||||||
static const int downloadProgressId = 1;
|
static const int downloadProgressId = 1;
|
||||||
static const int updateDownloadId = 2;
|
static const int updateDownloadId = 2;
|
||||||
|
static const int libraryScanId = 3;
|
||||||
static const String channelId = 'download_progress';
|
static const String channelId = 'download_progress';
|
||||||
static const String channelName = 'Download Progress';
|
static const String channelName = 'Download Progress';
|
||||||
static const String channelDescription = 'Shows download progress for tracks';
|
static const String channelDescription = 'Shows download progress for tracks';
|
||||||
|
static const String libraryChannelId = 'library_scan';
|
||||||
|
static const String libraryChannelName = 'Library Scan';
|
||||||
|
static const String libraryChannelDescription =
|
||||||
|
'Shows local library scan progress';
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_isInitialized) return;
|
if (_isInitialized) return;
|
||||||
|
|
||||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
const androidSettings = AndroidInitializationSettings(
|
||||||
|
'@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
const iosSettings = DarwinInitializationSettings(
|
const iosSettings = DarwinInitializationSettings(
|
||||||
requestAlertPermission: true,
|
requestAlertPermission: false,
|
||||||
requestBadgePermission: true,
|
requestBadgePermission: false,
|
||||||
requestSoundPermission: false,
|
requestSoundPermission: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -33,24 +45,86 @@ class NotificationService {
|
|||||||
await _notifications.initialize(settings: initSettings);
|
await _notifications.initialize(settings: initSettings);
|
||||||
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
await _notifications
|
final androidImpl = _notifications
|
||||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
.resolvePlatformSpecificImplementation<
|
||||||
?.createNotificationChannel(
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
const AndroidNotificationChannel(
|
>();
|
||||||
channelId,
|
await androidImpl?.createNotificationChannel(
|
||||||
channelName,
|
const AndroidNotificationChannel(
|
||||||
description: channelDescription,
|
channelId,
|
||||||
importance: Importance.low,
|
channelName,
|
||||||
showBadge: false,
|
description: channelDescription,
|
||||||
playSound: false,
|
importance: Importance.low,
|
||||||
enableVibration: false,
|
showBadge: false,
|
||||||
),
|
playSound: false,
|
||||||
);
|
enableVibration: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await androidImpl?.createNotificationChannel(
|
||||||
|
const AndroidNotificationChannel(
|
||||||
|
libraryChannelId,
|
||||||
|
libraryChannelName,
|
||||||
|
description: libraryChannelDescription,
|
||||||
|
importance: Importance.low,
|
||||||
|
showBadge: false,
|
||||||
|
playSound: false,
|
||||||
|
enableVibration: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _ensureNotificationPermission() async {
|
||||||
|
if (!Platform.isIOS) return true;
|
||||||
|
|
||||||
|
final status = await Permission.notification.status;
|
||||||
|
if (status.isGranted || status.isProvisional) return true;
|
||||||
|
|
||||||
|
if (_notificationPermissionRequested ||
|
||||||
|
status.isPermanentlyDenied ||
|
||||||
|
status.isRestricted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_notificationPermissionRequested = true;
|
||||||
|
final requested = await Permission.notification.request();
|
||||||
|
return requested.isGranted || requested.isProvisional;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showSafely({
|
||||||
|
required int id,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
required NotificationDetails details,
|
||||||
|
}) async {
|
||||||
|
if (!await _ensureNotificationPermission()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _notifications.show(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
notificationDetails: details,
|
||||||
|
);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
final isNotificationsNotAllowed =
|
||||||
|
Platform.isIOS &&
|
||||||
|
(e.code == 'Error 1' ||
|
||||||
|
(e.message?.contains('UNErrorDomain error 1') ?? false) ||
|
||||||
|
e.toString().contains('UNErrorDomain error 1'));
|
||||||
|
|
||||||
|
if (isNotificationsNotAllowed) {
|
||||||
|
debugPrint(
|
||||||
|
'iOS notifications not allowed; skipping local notification',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> showDownloadProgress({
|
Future<void> showDownloadProgress({
|
||||||
required String trackName,
|
required String trackName,
|
||||||
required String artistName,
|
required String artistName,
|
||||||
@@ -89,11 +163,11 @@ class NotificationService {
|
|||||||
iOS: iosDetails,
|
iOS: iosDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _notifications.show(
|
await _showSafely(
|
||||||
id: downloadProgressId,
|
id: downloadProgressId,
|
||||||
title: 'Downloading $trackName',
|
title: 'Downloading $trackName',
|
||||||
body: '$artistName • $percentage%',
|
body: '$artistName • $percentage%',
|
||||||
notificationDetails: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,11 +206,11 @@ class NotificationService {
|
|||||||
iOS: iosDetails,
|
iOS: iosDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _notifications.show(
|
await _showSafely(
|
||||||
id: downloadProgressId,
|
id: downloadProgressId,
|
||||||
title: 'Finalizing $trackName',
|
title: 'Finalizing $trackName',
|
||||||
body: '$artistName • Embedding metadata...',
|
body: '$artistName • Embedding metadata...',
|
||||||
notificationDetails: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,11 +256,11 @@ class NotificationService {
|
|||||||
iOS: iosDetails,
|
iOS: iosDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _notifications.show(
|
await _showSafely(
|
||||||
id: downloadProgressId,
|
id: downloadProgressId,
|
||||||
title: title,
|
title: title,
|
||||||
body: '$trackName - $artistName',
|
body: '$trackName - $artistName',
|
||||||
notificationDetails: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,11 +296,11 @@ class NotificationService {
|
|||||||
iOS: iosDetails,
|
iOS: iosDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _notifications.show(
|
await _showSafely(
|
||||||
id: downloadProgressId,
|
id: downloadProgressId,
|
||||||
title: title,
|
title: title,
|
||||||
body: '$completedCount tracks downloaded successfully',
|
body: '$completedCount tracks downloaded successfully',
|
||||||
notificationDetails: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +308,175 @@ class NotificationService {
|
|||||||
await _notifications.cancel(id: downloadProgressId);
|
await _notifications.cancel(id: downloadProgressId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> showLibraryScanProgress({
|
||||||
|
required double progress,
|
||||||
|
required int scannedFiles,
|
||||||
|
required int totalFiles,
|
||||||
|
String? currentFile,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
|
final clampedProgress = progress.clamp(0.0, 100.0);
|
||||||
|
final percentage = clampedProgress.round();
|
||||||
|
final progressBody = totalFiles > 0
|
||||||
|
? '$scannedFiles/$totalFiles files • $percentage%'
|
||||||
|
: '$scannedFiles files scanned • $percentage%';
|
||||||
|
final body = (currentFile != null && currentFile.isNotEmpty)
|
||||||
|
? '$progressBody\n$currentFile'
|
||||||
|
: progressBody;
|
||||||
|
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
libraryChannelId,
|
||||||
|
libraryChannelName,
|
||||||
|
channelDescription: libraryChannelDescription,
|
||||||
|
importance: Importance.low,
|
||||||
|
priority: Priority.low,
|
||||||
|
showProgress: true,
|
||||||
|
maxProgress: 100,
|
||||||
|
progress: percentage,
|
||||||
|
ongoing: true,
|
||||||
|
autoCancel: false,
|
||||||
|
playSound: false,
|
||||||
|
enableVibration: false,
|
||||||
|
onlyAlertOnce: true,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: false,
|
||||||
|
presentBadge: false,
|
||||||
|
presentSound: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _showSafely(
|
||||||
|
id: libraryScanId,
|
||||||
|
title: 'Scanning local library',
|
||||||
|
body: body,
|
||||||
|
details: details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showLibraryScanComplete({
|
||||||
|
required int totalTracks,
|
||||||
|
int excludedDownloadedCount = 0,
|
||||||
|
int errorCount = 0,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
|
final extras = <String>[];
|
||||||
|
if (excludedDownloadedCount > 0) {
|
||||||
|
extras.add('$excludedDownloadedCount excluded');
|
||||||
|
}
|
||||||
|
if (errorCount > 0) {
|
||||||
|
extras.add('$errorCount errors');
|
||||||
|
}
|
||||||
|
final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})';
|
||||||
|
|
||||||
|
const androidDetails = AndroidNotificationDetails(
|
||||||
|
libraryChannelId,
|
||||||
|
libraryChannelName,
|
||||||
|
channelDescription: libraryChannelDescription,
|
||||||
|
importance: Importance.defaultImportance,
|
||||||
|
priority: Priority.defaultPriority,
|
||||||
|
autoCancel: true,
|
||||||
|
playSound: false,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: false,
|
||||||
|
presentSound: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _showSafely(
|
||||||
|
id: libraryScanId,
|
||||||
|
title: 'Library scan complete',
|
||||||
|
body: '$totalTracks tracks indexed$suffix',
|
||||||
|
details: details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showLibraryScanFailed(String message) async {
|
||||||
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
|
const androidDetails = AndroidNotificationDetails(
|
||||||
|
libraryChannelId,
|
||||||
|
libraryChannelName,
|
||||||
|
channelDescription: libraryChannelDescription,
|
||||||
|
importance: Importance.defaultImportance,
|
||||||
|
priority: Priority.defaultPriority,
|
||||||
|
autoCancel: true,
|
||||||
|
playSound: false,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: false,
|
||||||
|
presentSound: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _showSafely(
|
||||||
|
id: libraryScanId,
|
||||||
|
title: 'Library scan failed',
|
||||||
|
body: message,
|
||||||
|
details: details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showLibraryScanCancelled() async {
|
||||||
|
if (!_isInitialized) await initialize();
|
||||||
|
|
||||||
|
const androidDetails = AndroidNotificationDetails(
|
||||||
|
libraryChannelId,
|
||||||
|
libraryChannelName,
|
||||||
|
channelDescription: libraryChannelDescription,
|
||||||
|
importance: Importance.defaultImportance,
|
||||||
|
priority: Priority.defaultPriority,
|
||||||
|
autoCancel: true,
|
||||||
|
playSound: false,
|
||||||
|
icon: '@mipmap/ic_launcher',
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: false,
|
||||||
|
presentSound: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const details = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _showSafely(
|
||||||
|
id: libraryScanId,
|
||||||
|
title: 'Library scan cancelled',
|
||||||
|
body: 'Scan stopped before completion.',
|
||||||
|
details: details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelLibraryScanNotification() async {
|
||||||
|
await _notifications.cancel(id: libraryScanId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> showUpdateDownloadProgress({
|
Future<void> showUpdateDownloadProgress({
|
||||||
required String version,
|
required String version,
|
||||||
required int received,
|
required int received,
|
||||||
@@ -273,11 +516,11 @@ class NotificationService {
|
|||||||
iOS: iosDetails,
|
iOS: iosDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _notifications.show(
|
await _showSafely(
|
||||||
id: updateDownloadId,
|
id: updateDownloadId,
|
||||||
title: 'Downloading SpotiFLAC v$version',
|
title: 'Downloading SpotiFLAC v$version',
|
||||||
body: '$receivedMB / $totalMB MB • $percentage%',
|
body: '$receivedMB / $totalMB MB • $percentage%',
|
||||||
notificationDetails: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,11 +549,11 @@ class NotificationService {
|
|||||||
iOS: iosDetails,
|
iOS: iosDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _notifications.show(
|
await _showSafely(
|
||||||
id: updateDownloadId,
|
id: updateDownloadId,
|
||||||
title: 'Update Ready',
|
title: 'Update Ready',
|
||||||
body: 'SpotiFLAC v$version downloaded. Tap to install.',
|
body: 'SpotiFLAC v$version downloaded. Tap to install.',
|
||||||
notificationDetails: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,11 +581,11 @@ class NotificationService {
|
|||||||
iOS: iosDetails,
|
iOS: iosDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _notifications.show(
|
await _showSafely(
|
||||||
id: updateDownloadId,
|
id: updateDownloadId,
|
||||||
title: 'Update Failed',
|
title: 'Update Failed',
|
||||||
body: 'Could not download update. Try again later.',
|
body: 'Could not download update. Try again later.',
|
||||||
notificationDetails: details,
|
details: details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
final _log = AppLogger('PlatformBridge');
|
final _log = AppLogger('PlatformBridge');
|
||||||
@@ -15,11 +16,16 @@ class PlatformBridge {
|
|||||||
|
|
||||||
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
|
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
|
||||||
_log.d('getSpotifyMetadata: $url');
|
_log.d('getSpotifyMetadata: $url');
|
||||||
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
|
final result = await _channel.invokeMethod('getSpotifyMetadata', {
|
||||||
|
'url': url,
|
||||||
|
});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
|
static Future<Map<String, dynamic>> searchSpotify(
|
||||||
|
String query, {
|
||||||
|
int limit = 10,
|
||||||
|
}) async {
|
||||||
_log.d('searchSpotify: "$query" (limit: $limit)');
|
_log.d('searchSpotify: "$query" (limit: $limit)');
|
||||||
final result = await _channel.invokeMethod('searchSpotify', {
|
final result = await _channel.invokeMethod('searchSpotify', {
|
||||||
'query': query,
|
'query': query,
|
||||||
@@ -28,7 +34,11 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
static Future<Map<String, dynamic>> searchSpotifyAll(
|
||||||
|
String query, {
|
||||||
|
int trackLimit = 15,
|
||||||
|
int artistLimit = 3,
|
||||||
|
}) async {
|
||||||
_log.d('searchSpotifyAll: "$query"');
|
_log.d('searchSpotifyAll: "$query"');
|
||||||
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
||||||
'query': query,
|
'query': query,
|
||||||
@@ -38,7 +48,10 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
static Future<Map<String, dynamic>> checkAvailability(
|
||||||
|
String spotifyId,
|
||||||
|
String isrc,
|
||||||
|
) async {
|
||||||
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
|
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
|
||||||
final result = await _channel.invokeMethod('checkAvailability', {
|
final result = await _channel.invokeMethod('checkAvailability', {
|
||||||
'spotify_id': spotifyId,
|
'spotify_id': spotifyId,
|
||||||
@@ -47,136 +60,34 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> downloadTrack({
|
static Future<Map<String, dynamic>> _invokeDownloadMethod(
|
||||||
required String isrc,
|
String method,
|
||||||
required String service,
|
DownloadRequestPayload payload,
|
||||||
required String spotifyId,
|
) async {
|
||||||
required String trackName,
|
final request = jsonEncode(payload.toJson());
|
||||||
required String artistName,
|
final result = await _channel.invokeMethod(method, request);
|
||||||
required String albumName,
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
String? albumArtist,
|
|
||||||
String? coverUrl,
|
|
||||||
required String outputDir,
|
|
||||||
required String filenameFormat,
|
|
||||||
String quality = 'LOSSLESS',
|
|
||||||
bool embedLyrics = true,
|
|
||||||
bool embedMaxQualityCover = true,
|
|
||||||
int trackNumber = 1,
|
|
||||||
int discNumber = 1,
|
|
||||||
int totalTracks = 1,
|
|
||||||
String? releaseDate,
|
|
||||||
String? itemId,
|
|
||||||
int durationMs = 0,
|
|
||||||
String storageMode = 'app',
|
|
||||||
String safTreeUri = '',
|
|
||||||
String safRelativeDir = '',
|
|
||||||
String safFileName = '',
|
|
||||||
String safOutputExt = '',
|
|
||||||
}) async {
|
|
||||||
_log.i('downloadTrack: "$trackName" by $artistName via $service');
|
|
||||||
final request = jsonEncode({
|
|
||||||
'isrc': isrc,
|
|
||||||
'service': service,
|
|
||||||
'spotify_id': spotifyId,
|
|
||||||
'track_name': trackName,
|
|
||||||
'artist_name': artistName,
|
|
||||||
'album_name': albumName,
|
|
||||||
'album_artist': albumArtist ?? artistName,
|
|
||||||
'cover_url': coverUrl,
|
|
||||||
'output_dir': outputDir,
|
|
||||||
'filename_format': filenameFormat,
|
|
||||||
'quality': quality,
|
|
||||||
'embed_lyrics': embedLyrics,
|
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
|
||||||
'track_number': trackNumber,
|
|
||||||
'disc_number': discNumber,
|
|
||||||
'total_tracks': totalTracks,
|
|
||||||
'release_date': releaseDate ?? '',
|
|
||||||
'item_id': itemId ?? '',
|
|
||||||
'duration_ms': durationMs,
|
|
||||||
'storage_mode': storageMode,
|
|
||||||
'saf_tree_uri': safTreeUri,
|
|
||||||
'saf_relative_dir': safRelativeDir,
|
|
||||||
'saf_file_name': safFileName,
|
|
||||||
'saf_output_ext': safOutputExt,
|
|
||||||
});
|
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadTrack', request);
|
|
||||||
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
|
||||||
if (response['success'] == true) {
|
|
||||||
_log.i('Download success: ${response['file_path']}');
|
|
||||||
} else {
|
|
||||||
_log.w('Download failed: ${response['error']}');
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> downloadWithFallback({
|
static Future<Map<String, dynamic>> downloadByStrategy({
|
||||||
required String isrc,
|
required DownloadRequestPayload payload,
|
||||||
required String spotifyId,
|
bool? useExtensions,
|
||||||
required String trackName,
|
bool? useFallback,
|
||||||
required String artistName,
|
|
||||||
required String albumName,
|
|
||||||
String? albumArtist,
|
|
||||||
String? coverUrl,
|
|
||||||
required String outputDir,
|
|
||||||
required String filenameFormat,
|
|
||||||
String quality = 'LOSSLESS',
|
|
||||||
bool embedLyrics = true,
|
|
||||||
bool embedMaxQualityCover = true,
|
|
||||||
int trackNumber = 1,
|
|
||||||
int discNumber = 1,
|
|
||||||
int totalTracks = 1,
|
|
||||||
String? releaseDate,
|
|
||||||
String preferredService = 'tidal',
|
|
||||||
String? itemId,
|
|
||||||
int durationMs = 0,
|
|
||||||
String? genre,
|
|
||||||
String? label,
|
|
||||||
String? copyright,
|
|
||||||
String lyricsMode = 'embed',
|
|
||||||
String storageMode = 'app',
|
|
||||||
String safTreeUri = '',
|
|
||||||
String safRelativeDir = '',
|
|
||||||
String safFileName = '',
|
|
||||||
String safOutputExt = '',
|
|
||||||
}) async {
|
}) async {
|
||||||
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
|
final routedPayload = payload.withStrategy(
|
||||||
final request = jsonEncode({
|
useExtensions: useExtensions,
|
||||||
'isrc': isrc,
|
useFallback: useFallback,
|
||||||
'service': preferredService,
|
);
|
||||||
'spotify_id': spotifyId,
|
_log.i(
|
||||||
'track_name': trackName,
|
'downloadByStrategy: "${payload.trackName}" by ${payload.artistName} '
|
||||||
'artist_name': artistName,
|
'(service: ${payload.service}, ext: ${routedPayload.useExtensions}, fallback: ${routedPayload.useFallback})',
|
||||||
'album_name': albumName,
|
);
|
||||||
'album_artist': albumArtist ?? artistName,
|
final response = await _invokeDownloadMethod(
|
||||||
'cover_url': coverUrl,
|
'downloadByStrategy',
|
||||||
'output_dir': outputDir,
|
routedPayload,
|
||||||
'filename_format': filenameFormat,
|
);
|
||||||
'quality': quality,
|
|
||||||
'embed_lyrics': embedLyrics,
|
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
|
||||||
'track_number': trackNumber,
|
|
||||||
'disc_number': discNumber,
|
|
||||||
'total_tracks': totalTracks,
|
|
||||||
'release_date': releaseDate ?? '',
|
|
||||||
'item_id': itemId ?? '',
|
|
||||||
'duration_ms': durationMs,
|
|
||||||
'genre': genre ?? '',
|
|
||||||
'label': label ?? '',
|
|
||||||
'copyright': copyright ?? '',
|
|
||||||
'lyrics_mode': lyricsMode,
|
|
||||||
'storage_mode': storageMode,
|
|
||||||
'saf_tree_uri': safTreeUri,
|
|
||||||
'saf_relative_dir': safRelativeDir,
|
|
||||||
'saf_file_name': safFileName,
|
|
||||||
'saf_output_ext': safOutputExt,
|
|
||||||
});
|
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
|
||||||
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
|
||||||
if (response['success'] == true) {
|
if (response['success'] == true) {
|
||||||
final service = response['service'] ?? 'unknown';
|
final service = response['service'] ?? payload.service;
|
||||||
final filePath = response['file_path'] ?? '';
|
final filePath = response['file_path'] ?? '';
|
||||||
final bitDepth = response['actual_bit_depth'];
|
final bitDepth = response['actual_bit_depth'];
|
||||||
final sampleRate = response['actual_sample_rate'];
|
final sampleRate = response['actual_sample_rate'];
|
||||||
@@ -222,7 +133,10 @@ class PlatformBridge {
|
|||||||
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> checkDuplicate(String outputDir, String isrc) async {
|
static Future<Map<String, dynamic>> checkDuplicate(
|
||||||
|
String outputDir,
|
||||||
|
String isrc,
|
||||||
|
) async {
|
||||||
final result = await _channel.invokeMethod('checkDuplicate', {
|
final result = await _channel.invokeMethod('checkDuplicate', {
|
||||||
'output_dir': outputDir,
|
'output_dir': outputDir,
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
@@ -230,7 +144,10 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<String> buildFilename(String template, Map<String, dynamic> metadata) async {
|
static Future<String> buildFilename(
|
||||||
|
String template,
|
||||||
|
Map<String, dynamic> metadata,
|
||||||
|
) async {
|
||||||
final result = await _channel.invokeMethod('buildFilename', {
|
final result = await _channel.invokeMethod('buildFilename', {
|
||||||
'template': template,
|
'template': template,
|
||||||
'metadata': jsonEncode(metadata),
|
'metadata': jsonEncode(metadata),
|
||||||
@@ -415,7 +332,9 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> reEnrichFile(Map<String, dynamic> request) async {
|
static Future<Map<String, dynamic>> reEnrichFile(
|
||||||
|
Map<String, dynamic> request,
|
||||||
|
) async {
|
||||||
final requestJSON = jsonEncode(request);
|
final requestJSON = jsonEncode(request);
|
||||||
final result = await _channel.invokeMethod('reEnrichFile', {
|
final result = await _channel.invokeMethod('reEnrichFile', {
|
||||||
'request_json': requestJSON,
|
'request_json': requestJSON,
|
||||||
@@ -488,7 +407,10 @@ class PlatformBridge {
|
|||||||
return result as bool;
|
return result as bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
static Future<void> setSpotifyCredentials(
|
||||||
|
String clientId,
|
||||||
|
String clientSecret,
|
||||||
|
) async {
|
||||||
await _channel.invokeMethod('setSpotifyCredentials', {
|
await _channel.invokeMethod('setSpotifyCredentials', {
|
||||||
'client_id': clientId,
|
'client_id': clientId,
|
||||||
'client_secret': clientSecret,
|
'client_secret': clientSecret,
|
||||||
@@ -500,7 +422,9 @@ class PlatformBridge {
|
|||||||
return result as bool;
|
return result as bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> preWarmTrackCache(List<Map<String, String>> tracks) async {
|
static Future<void> preWarmTrackCache(
|
||||||
|
List<Map<String, String>> tracks,
|
||||||
|
) async {
|
||||||
final tracksJson = jsonEncode(tracks);
|
final tracksJson = jsonEncode(tracks);
|
||||||
await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson});
|
await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson});
|
||||||
}
|
}
|
||||||
@@ -514,7 +438,12 @@ class PlatformBridge {
|
|||||||
await _channel.invokeMethod('clearTrackCache');
|
await _channel.invokeMethod('clearTrackCache');
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 2, String? filter}) async {
|
static Future<Map<String, dynamic>> searchDeezerAll(
|
||||||
|
String query, {
|
||||||
|
int trackLimit = 15,
|
||||||
|
int artistLimit = 2,
|
||||||
|
String? filter,
|
||||||
|
}) async {
|
||||||
final result = await _channel.invokeMethod('searchDeezerAll', {
|
final result = await _channel.invokeMethod('searchDeezerAll', {
|
||||||
'query': query,
|
'query': query,
|
||||||
'track_limit': trackLimit,
|
'track_limit': trackLimit,
|
||||||
@@ -524,13 +453,18 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> getDeezerMetadata(String resourceType, String resourceId) async {
|
static Future<Map<String, dynamic>> getDeezerMetadata(
|
||||||
|
String resourceType,
|
||||||
|
String resourceId,
|
||||||
|
) async {
|
||||||
final result = await _channel.invokeMethod('getDeezerMetadata', {
|
final result = await _channel.invokeMethod('getDeezerMetadata', {
|
||||||
'resource_type': resourceType,
|
'resource_type': resourceType,
|
||||||
'resource_id': resourceId,
|
'resource_id': resourceId,
|
||||||
});
|
});
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
throw Exception('getDeezerMetadata returned null for $resourceType:$resourceId');
|
throw Exception(
|
||||||
|
'getDeezerMetadata returned null for $resourceType:$resourceId',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
@@ -545,17 +479,25 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> convertTidalToSpotifyDeezer(String tidalUrl) async {
|
static Future<Map<String, dynamic>> convertTidalToSpotifyDeezer(
|
||||||
final result = await _channel.invokeMethod('convertTidalToSpotifyDeezer', {'url': tidalUrl});
|
String tidalUrl,
|
||||||
|
) async {
|
||||||
|
final result = await _channel.invokeMethod('convertTidalToSpotifyDeezer', {
|
||||||
|
'url': tidalUrl,
|
||||||
|
});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
|
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
|
||||||
final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc});
|
final result = await _channel.invokeMethod('searchDeezerByISRC', {
|
||||||
|
'isrc': isrc,
|
||||||
|
});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, String>?> getDeezerExtendedMetadata(String trackId) async {
|
static Future<Map<String, String>?> getDeezerExtendedMetadata(
|
||||||
|
String trackId,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
final result = await _channel.invokeMethod('getDeezerExtendedMetadata', {
|
final result = await _channel.invokeMethod('getDeezerExtendedMetadata', {
|
||||||
'track_id': trackId,
|
'track_id': trackId,
|
||||||
@@ -565,6 +507,7 @@ class PlatformBridge {
|
|||||||
return {
|
return {
|
||||||
'genre': data['genre'] as String? ?? '',
|
'genre': data['genre'] as String? ?? '',
|
||||||
'label': data['label'] as String? ?? '',
|
'label': data['label'] as String? ?? '',
|
||||||
|
'copyright': data['copyright'] as String? ?? '',
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to get Deezer extended metadata for $trackId: $e');
|
_log.w('Failed to get Deezer extended metadata for $trackId: $e');
|
||||||
@@ -572,7 +515,10 @@ class PlatformBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async {
|
static Future<Map<String, dynamic>> convertSpotifyToDeezer(
|
||||||
|
String resourceType,
|
||||||
|
String spotifyId,
|
||||||
|
) async {
|
||||||
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
|
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
|
||||||
'resource_type': resourceType,
|
'resource_type': resourceType,
|
||||||
'spotify_id': spotifyId,
|
'spotify_id': spotifyId,
|
||||||
@@ -580,8 +526,13 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(String url) async {
|
static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(
|
||||||
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
|
String url,
|
||||||
|
) async {
|
||||||
|
final result = await _channel.invokeMethod(
|
||||||
|
'getSpotifyMetadataWithFallback',
|
||||||
|
{'url': url},
|
||||||
|
);
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,7 +543,9 @@ class PlatformBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
|
static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
|
||||||
final result = await _channel.invokeMethod('getLogsSince', {'index': index});
|
final result = await _channel.invokeMethod('getLogsSince', {
|
||||||
|
'index': index,
|
||||||
|
});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,8 +562,10 @@ class PlatformBridge {
|
|||||||
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
|
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> initExtensionSystem(
|
||||||
static Future<void> initExtensionSystem(String extensionsDir, String dataDir) async {
|
String extensionsDir,
|
||||||
|
String dataDir,
|
||||||
|
) async {
|
||||||
_log.d('initExtensionSystem: $extensionsDir, $dataDir');
|
_log.d('initExtensionSystem: $extensionsDir, $dataDir');
|
||||||
await _channel.invokeMethod('initExtensionSystem', {
|
await _channel.invokeMethod('initExtensionSystem', {
|
||||||
'extensions_dir': extensionsDir,
|
'extensions_dir': extensionsDir,
|
||||||
@@ -618,7 +573,9 @@ class PlatformBridge {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> loadExtensionsFromDir(String dirPath) async {
|
static Future<Map<String, dynamic>> loadExtensionsFromDir(
|
||||||
|
String dirPath,
|
||||||
|
) async {
|
||||||
_log.d('loadExtensionsFromDir: $dirPath');
|
_log.d('loadExtensionsFromDir: $dirPath');
|
||||||
final result = await _channel.invokeMethod('loadExtensionsFromDir', {
|
final result = await _channel.invokeMethod('loadExtensionsFromDir', {
|
||||||
'dir_path': dirPath,
|
'dir_path': dirPath,
|
||||||
@@ -626,7 +583,9 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> loadExtensionFromPath(String filePath) async {
|
static Future<Map<String, dynamic>> loadExtensionFromPath(
|
||||||
|
String filePath,
|
||||||
|
) async {
|
||||||
_log.d('loadExtensionFromPath: $filePath');
|
_log.d('loadExtensionFromPath: $filePath');
|
||||||
final result = await _channel.invokeMethod('loadExtensionFromPath', {
|
final result = await _channel.invokeMethod('loadExtensionFromPath', {
|
||||||
'file_path': filePath,
|
'file_path': filePath,
|
||||||
@@ -656,7 +615,9 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
static Future<Map<String, dynamic>> checkExtensionUpgrade(
|
||||||
|
String filePath,
|
||||||
|
) async {
|
||||||
_log.d('checkExtensionUpgrade: $filePath');
|
_log.d('checkExtensionUpgrade: $filePath');
|
||||||
final result = await _channel.invokeMethod('checkExtensionUpgrade', {
|
final result = await _channel.invokeMethod('checkExtensionUpgrade', {
|
||||||
'file_path': filePath,
|
'file_path': filePath,
|
||||||
@@ -670,7 +631,10 @@ class PlatformBridge {
|
|||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
static Future<void> setExtensionEnabled(
|
||||||
|
String extensionId,
|
||||||
|
bool enabled,
|
||||||
|
) async {
|
||||||
_log.d('setExtensionEnabled: $extensionId = $enabled');
|
_log.d('setExtensionEnabled: $extensionId = $enabled');
|
||||||
await _channel.invokeMethod('setExtensionEnabled', {
|
await _channel.invokeMethod('setExtensionEnabled', {
|
||||||
'extension_id': extensionId,
|
'extension_id': extensionId,
|
||||||
@@ -691,7 +655,9 @@ class PlatformBridge {
|
|||||||
return list.map((e) => e as String).toList();
|
return list.map((e) => e as String).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setMetadataProviderPriority(List<String> providerIds) async {
|
static Future<void> setMetadataProviderPriority(
|
||||||
|
List<String> providerIds,
|
||||||
|
) async {
|
||||||
_log.d('setMetadataProviderPriority: $providerIds');
|
_log.d('setMetadataProviderPriority: $providerIds');
|
||||||
await _channel.invokeMethod('setMetadataProviderPriority', {
|
await _channel.invokeMethod('setMetadataProviderPriority', {
|
||||||
'priority': jsonEncode(providerIds),
|
'priority': jsonEncode(providerIds),
|
||||||
@@ -704,14 +670,19 @@ class PlatformBridge {
|
|||||||
return list.map((e) => e as String).toList();
|
return list.map((e) => e as String).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
static Future<Map<String, dynamic>> getExtensionSettings(
|
||||||
|
String extensionId,
|
||||||
|
) async {
|
||||||
final result = await _channel.invokeMethod('getExtensionSettings', {
|
final result = await _channel.invokeMethod('getExtensionSettings', {
|
||||||
'extension_id': extensionId,
|
'extension_id': extensionId,
|
||||||
});
|
});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
static Future<void> setExtensionSettings(
|
||||||
|
String extensionId,
|
||||||
|
Map<String, dynamic> settings,
|
||||||
|
) async {
|
||||||
_log.d('setExtensionSettings: $extensionId');
|
_log.d('setExtensionSettings: $extensionId');
|
||||||
await _channel.invokeMethod('setExtensionSettings', {
|
await _channel.invokeMethod('setExtensionSettings', {
|
||||||
'extension_id': extensionId,
|
'extension_id': extensionId,
|
||||||
@@ -719,7 +690,10 @@ class PlatformBridge {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> invokeExtensionAction(String extensionId, String actionName) async {
|
static Future<Map<String, dynamic>> invokeExtensionAction(
|
||||||
|
String extensionId,
|
||||||
|
String actionName,
|
||||||
|
) async {
|
||||||
_log.d('invokeExtensionAction: $extensionId.$actionName');
|
_log.d('invokeExtensionAction: $extensionId.$actionName');
|
||||||
final result = await _channel.invokeMethod('invokeExtensionAction', {
|
final result = await _channel.invokeMethod('invokeExtensionAction', {
|
||||||
'extension_id': extensionId,
|
'extension_id': extensionId,
|
||||||
@@ -731,7 +705,10 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result) as Map<String, dynamic>;
|
return jsonDecode(result) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async {
|
static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(
|
||||||
|
String query, {
|
||||||
|
int limit = 20,
|
||||||
|
}) async {
|
||||||
_log.d('searchTracksWithExtensions: "$query"');
|
_log.d('searchTracksWithExtensions: "$query"');
|
||||||
final result = await _channel.invokeMethod('searchTracksWithExtensions', {
|
final result = await _channel.invokeMethod('searchTracksWithExtensions', {
|
||||||
'query': query,
|
'query': query,
|
||||||
@@ -741,78 +718,14 @@ class PlatformBridge {
|
|||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> downloadWithExtensions({
|
|
||||||
required String isrc,
|
|
||||||
required String spotifyId,
|
|
||||||
required String trackName,
|
|
||||||
required String artistName,
|
|
||||||
required String albumName,
|
|
||||||
String? albumArtist,
|
|
||||||
String? coverUrl,
|
|
||||||
required String outputDir,
|
|
||||||
required String filenameFormat,
|
|
||||||
String quality = 'LOSSLESS',
|
|
||||||
bool embedLyrics = true,
|
|
||||||
bool embedMaxQualityCover = true,
|
|
||||||
int trackNumber = 1,
|
|
||||||
int discNumber = 1,
|
|
||||||
int totalTracks = 1,
|
|
||||||
String? releaseDate,
|
|
||||||
String? itemId,
|
|
||||||
int durationMs = 0,
|
|
||||||
String? source,
|
|
||||||
String? genre,
|
|
||||||
String? label,
|
|
||||||
String lyricsMode = 'embed',
|
|
||||||
String? preferredService,
|
|
||||||
String storageMode = 'app',
|
|
||||||
String safTreeUri = '',
|
|
||||||
String safRelativeDir = '',
|
|
||||||
String safFileName = '',
|
|
||||||
String safOutputExt = '',
|
|
||||||
}) async {
|
|
||||||
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}${preferredService != null ? ' (service: $preferredService)' : ''}');
|
|
||||||
final request = jsonEncode({
|
|
||||||
'isrc': isrc,
|
|
||||||
'spotify_id': spotifyId,
|
|
||||||
'track_name': trackName,
|
|
||||||
'artist_name': artistName,
|
|
||||||
'album_name': albumName,
|
|
||||||
'album_artist': albumArtist ?? artistName,
|
|
||||||
'cover_url': coverUrl,
|
|
||||||
'output_dir': outputDir,
|
|
||||||
'filename_format': filenameFormat,
|
|
||||||
'quality': quality,
|
|
||||||
'embed_lyrics': embedLyrics,
|
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
|
||||||
'track_number': trackNumber,
|
|
||||||
'disc_number': discNumber,
|
|
||||||
'total_tracks': totalTracks,
|
|
||||||
'release_date': releaseDate ?? '',
|
|
||||||
'item_id': itemId ?? '',
|
|
||||||
'duration_ms': durationMs,
|
|
||||||
'source': source ?? '',
|
|
||||||
'genre': genre ?? '',
|
|
||||||
'label': label ?? '',
|
|
||||||
'lyrics_mode': lyricsMode,
|
|
||||||
'service': preferredService ?? '',
|
|
||||||
'storage_mode': storageMode,
|
|
||||||
'saf_tree_uri': safTreeUri,
|
|
||||||
'saf_relative_dir': safRelativeDir,
|
|
||||||
'saf_file_name': safFileName,
|
|
||||||
'saf_output_ext': safOutputExt,
|
|
||||||
});
|
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadWithExtensions', request);
|
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> cleanupExtensions() async {
|
static Future<void> cleanupExtensions() async {
|
||||||
_log.d('cleanupExtensions');
|
_log.d('cleanupExtensions');
|
||||||
await _channel.invokeMethod('cleanupExtensions');
|
await _channel.invokeMethod('cleanupExtensions');
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>?> getExtensionPendingAuth(String extensionId) async {
|
static Future<Map<String, dynamic>?> getExtensionPendingAuth(
|
||||||
|
String extensionId,
|
||||||
|
) async {
|
||||||
final result = await _channel.invokeMethod('getExtensionPendingAuth', {
|
final result = await _channel.invokeMethod('getExtensionPendingAuth', {
|
||||||
'extension_id': extensionId,
|
'extension_id': extensionId,
|
||||||
});
|
});
|
||||||
@@ -820,7 +733,10 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setExtensionAuthCode(String extensionId, String authCode) async {
|
static Future<void> setExtensionAuthCode(
|
||||||
|
String extensionId,
|
||||||
|
String authCode,
|
||||||
|
) async {
|
||||||
_log.d('setExtensionAuthCode: $extensionId');
|
_log.d('setExtensionAuthCode: $extensionId');
|
||||||
await _channel.invokeMethod('setExtensionAuthCode', {
|
await _channel.invokeMethod('setExtensionAuthCode', {
|
||||||
'extension_id': extensionId,
|
'extension_id': extensionId,
|
||||||
@@ -862,7 +778,9 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>?> getPendingFFmpegCommand(String commandId) async {
|
static Future<Map<String, dynamic>?> getPendingFFmpegCommand(
|
||||||
|
String commandId,
|
||||||
|
) async {
|
||||||
final result = await _channel.invokeMethod('getPendingFFmpegCommand', {
|
final result = await _channel.invokeMethod('getPendingFFmpegCommand', {
|
||||||
'command_id': commandId,
|
'command_id': commandId,
|
||||||
});
|
});
|
||||||
@@ -884,7 +802,8 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<Map<String, dynamic>>> getAllPendingFFmpegCommands() async {
|
static Future<List<Map<String, dynamic>>>
|
||||||
|
getAllPendingFFmpegCommands() async {
|
||||||
final result = await _channel.invokeMethod('getAllPendingFFmpegCommands');
|
final result = await _channel.invokeMethod('getAllPendingFFmpegCommands');
|
||||||
final list = jsonDecode(result as String) as List<dynamic>;
|
final list = jsonDecode(result as String) as List<dynamic>;
|
||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
@@ -910,7 +829,9 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>?> handleURLWithExtension(String url) async {
|
static Future<Map<String, dynamic>?> handleURLWithExtension(
|
||||||
|
String url,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
final result = await _channel.invokeMethod('handleURLWithExtension', {
|
final result = await _channel.invokeMethod('handleURLWithExtension', {
|
||||||
'url': url,
|
'url': url,
|
||||||
@@ -923,9 +844,7 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<String?> findURLHandler(String url) async {
|
static Future<String?> findURLHandler(String url) async {
|
||||||
final result = await _channel.invokeMethod('findURLHandler', {
|
final result = await _channel.invokeMethod('findURLHandler', {'url': url});
|
||||||
'url': url,
|
|
||||||
});
|
|
||||||
if (result == null || result == '') return null;
|
if (result == null || result == '') return null;
|
||||||
return result as String;
|
return result as String;
|
||||||
}
|
}
|
||||||
@@ -987,7 +906,9 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>?> getExtensionHomeFeed(String extensionId) async {
|
static Future<Map<String, dynamic>?> getExtensionHomeFeed(
|
||||||
|
String extensionId,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
|
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
|
||||||
'extension_id': extensionId,
|
'extension_id': extensionId,
|
||||||
@@ -1000,11 +921,14 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>?> getExtensionBrowseCategories(String extensionId) async {
|
static Future<Map<String, dynamic>?> getExtensionBrowseCategories(
|
||||||
|
String extensionId,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
final result = await _channel.invokeMethod('getExtensionBrowseCategories', {
|
final result = await _channel.invokeMethod(
|
||||||
'extension_id': extensionId,
|
'getExtensionBrowseCategories',
|
||||||
});
|
{'extension_id': extensionId},
|
||||||
|
);
|
||||||
if (result == null || result == '') return null;
|
if (result == null || result == '') return null;
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1023,9 +947,11 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan a folder for audio files and read their metadata
|
/// Scan a folder for audio files and read their metadata
|
||||||
/// Returns a list of track metadata
|
/// Returns a list of track metadata
|
||||||
static Future<List<Map<String, dynamic>>> scanLibraryFolder(String folderPath) async {
|
static Future<List<Map<String, dynamic>>> scanLibraryFolder(
|
||||||
|
String folderPath,
|
||||||
|
) async {
|
||||||
_log.i('scanLibraryFolder: $folderPath');
|
_log.i('scanLibraryFolder: $folderPath');
|
||||||
final result = await _channel.invokeMethod('scanLibraryFolder', {
|
final result = await _channel.invokeMethod('scanLibraryFolder', {
|
||||||
'folder_path': folderPath,
|
'folder_path': folderPath,
|
||||||
@@ -1042,7 +968,9 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
String folderPath,
|
String folderPath,
|
||||||
Map<String, int> existingFiles,
|
Map<String, int> existingFiles,
|
||||||
) async {
|
) async {
|
||||||
_log.i('scanLibraryFolderIncremental: $folderPath (${existingFiles.length} existing files)');
|
_log.i(
|
||||||
|
'scanLibraryFolderIncremental: $folderPath (${existingFiles.length} existing files)',
|
||||||
|
);
|
||||||
final result = await _channel.invokeMethod('scanLibraryFolderIncremental', {
|
final result = await _channel.invokeMethod('scanLibraryFolderIncremental', {
|
||||||
'folder_path': folderPath,
|
'folder_path': folderPath,
|
||||||
'existing_files': jsonEncode(existingFiles),
|
'existing_files': jsonEncode(existingFiles),
|
||||||
@@ -1065,7 +993,9 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
String treeUri,
|
String treeUri,
|
||||||
Map<String, int> existingFiles,
|
Map<String, int> existingFiles,
|
||||||
) async {
|
) async {
|
||||||
_log.i('scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)');
|
_log.i(
|
||||||
|
'scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)',
|
||||||
|
);
|
||||||
final result = await _channel.invokeMethod('scanSafTreeIncremental', {
|
final result = await _channel.invokeMethod('scanSafTreeIncremental', {
|
||||||
'tree_uri': treeUri,
|
'tree_uri': treeUri,
|
||||||
'existing_files': jsonEncode(existingFiles),
|
'existing_files': jsonEncode(existingFiles),
|
||||||
@@ -1095,7 +1025,9 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read metadata from a single audio file
|
/// Read metadata from a single audio file
|
||||||
static Future<Map<String, dynamic>?> readAudioMetadata(String filePath) async {
|
static Future<Map<String, dynamic>?> readAudioMetadata(
|
||||||
|
String filePath,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
final result = await _channel.invokeMethod('readAudioMetadata', {
|
final result = await _channel.invokeMethod('readAudioMetadata', {
|
||||||
'file_path': filePath,
|
'file_path': filePath,
|
||||||
@@ -1108,7 +1040,6 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> runPostProcessing(
|
static Future<Map<String, dynamic>> runPostProcessing(
|
||||||
String filePath, {
|
String filePath, {
|
||||||
Map<String, dynamic>? metadata,
|
Map<String, dynamic>? metadata,
|
||||||
@@ -1143,13 +1074,14 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static Future<void> initExtensionStore(String cacheDir) async {
|
static Future<void> initExtensionStore(String cacheDir) async {
|
||||||
_log.d('initExtensionStore: $cacheDir');
|
_log.d('initExtensionStore: $cacheDir');
|
||||||
await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir});
|
await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir});
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<Map<String, dynamic>>> getStoreExtensions({bool forceRefresh = false}) async {
|
static Future<List<Map<String, dynamic>>> getStoreExtensions({
|
||||||
|
bool forceRefresh = false,
|
||||||
|
}) async {
|
||||||
_log.d('getStoreExtensions (forceRefresh: $forceRefresh)');
|
_log.d('getStoreExtensions (forceRefresh: $forceRefresh)');
|
||||||
final result = await _channel.invokeMethod('getStoreExtensions', {
|
final result = await _channel.invokeMethod('getStoreExtensions', {
|
||||||
'force_refresh': forceRefresh,
|
'force_refresh': forceRefresh,
|
||||||
@@ -1158,7 +1090,10 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<Map<String, dynamic>>> searchStoreExtensions(String query, {String? category}) async {
|
static Future<List<Map<String, dynamic>>> searchStoreExtensions(
|
||||||
|
String query, {
|
||||||
|
String? category,
|
||||||
|
}) async {
|
||||||
_log.d('searchStoreExtensions: "$query" (category: $category)');
|
_log.d('searchStoreExtensions: "$query" (category: $category)');
|
||||||
final result = await _channel.invokeMethod('searchStoreExtensions', {
|
final result = await _channel.invokeMethod('searchStoreExtensions', {
|
||||||
'query': query,
|
'query': query,
|
||||||
@@ -1174,7 +1109,10 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
return list.cast<String>();
|
return list.cast<String>();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<String> downloadStoreExtension(String extensionId, String destDir) async {
|
static Future<String> downloadStoreExtension(
|
||||||
|
String extensionId,
|
||||||
|
String destDir,
|
||||||
|
) async {
|
||||||
_log.i('downloadStoreExtension: $extensionId to $destDir');
|
_log.i('downloadStoreExtension: $extensionId to $destDir');
|
||||||
final result = await _channel.invokeMethod('downloadStoreExtension', {
|
final result = await _channel.invokeMethod('downloadStoreExtension', {
|
||||||
'extension_id': extensionId,
|
'extension_id': extensionId,
|
||||||
@@ -1189,65 +1127,4 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== YOUTUBE / COBALT ====================
|
// ==================== YOUTUBE / COBALT ====================
|
||||||
|
|
||||||
/// Download a track from YouTube using the Cobalt API.
|
|
||||||
/// YouTube is a lossy-only provider (Opus 256kbps or MP3 320kbps).
|
|
||||||
/// It does NOT participate in the lossless fallback chain.
|
|
||||||
static Future<Map<String, dynamic>> downloadFromYouTube({
|
|
||||||
required String trackName,
|
|
||||||
required String artistName,
|
|
||||||
required String albumName,
|
|
||||||
String? albumArtist,
|
|
||||||
String? coverUrl,
|
|
||||||
required String outputDir,
|
|
||||||
required String filenameFormat,
|
|
||||||
String quality = 'opus_256',
|
|
||||||
int trackNumber = 1,
|
|
||||||
int discNumber = 1,
|
|
||||||
String? releaseDate,
|
|
||||||
String? itemId,
|
|
||||||
int durationMs = 0,
|
|
||||||
String? isrc,
|
|
||||||
String? spotifyId,
|
|
||||||
String? deezerId,
|
|
||||||
String storageMode = 'app',
|
|
||||||
String safTreeUri = '',
|
|
||||||
String safRelativeDir = '',
|
|
||||||
String safFileName = '',
|
|
||||||
String safOutputExt = '',
|
|
||||||
}) async {
|
|
||||||
_log.i('downloadFromYouTube: "$trackName" by $artistName (quality: $quality)');
|
|
||||||
final request = jsonEncode({
|
|
||||||
'track_name': trackName,
|
|
||||||
'artist_name': artistName,
|
|
||||||
'album_name': albumName,
|
|
||||||
'album_artist': albumArtist ?? artistName,
|
|
||||||
'cover_url': coverUrl,
|
|
||||||
'output_dir': outputDir,
|
|
||||||
'filename_format': filenameFormat,
|
|
||||||
'quality': quality,
|
|
||||||
'track_number': trackNumber,
|
|
||||||
'disc_number': discNumber,
|
|
||||||
'release_date': releaseDate ?? '',
|
|
||||||
'item_id': itemId ?? '',
|
|
||||||
'duration_ms': durationMs,
|
|
||||||
'isrc': isrc ?? '',
|
|
||||||
'spotify_id': spotifyId ?? '',
|
|
||||||
'deezer_id': deezerId ?? '',
|
|
||||||
'storage_mode': storageMode,
|
|
||||||
'saf_tree_uri': safTreeUri,
|
|
||||||
'saf_relative_dir': safRelativeDir,
|
|
||||||
'saf_file_name': safFileName,
|
|
||||||
'saf_output_ext': safOutputExt,
|
|
||||||
});
|
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadFromYouTube', request);
|
|
||||||
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
|
||||||
if (response['success'] == true) {
|
|
||||||
_log.i('YouTube download success: ${response['file_path']}');
|
|
||||||
} else {
|
|
||||||
_log.w('YouTube download failed: ${response['error']}');
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ final _iosContainerRootPattern = RegExp(
|
|||||||
r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$',
|
r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$',
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
);
|
);
|
||||||
|
final _iosContainerPathWithoutLeadingSlashPattern = RegExp(
|
||||||
|
r'^(private/)?var/mobile/Containers/Data/Application/[A-F0-9\-]+/.+',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
final _iosLegacyRelativeDocumentsPattern = RegExp(
|
||||||
|
r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
/// Checks if a path is a valid writable directory on iOS.
|
/// Checks if a path is a valid writable directory on iOS.
|
||||||
/// Returns false if:
|
/// Returns false if:
|
||||||
@@ -21,6 +29,7 @@ final _iosContainerRootPattern = RegExp(
|
|||||||
bool isValidIosWritablePath(String path) {
|
bool isValidIosWritablePath(String path) {
|
||||||
if (!Platform.isIOS) return true;
|
if (!Platform.isIOS) return true;
|
||||||
if (path.isEmpty) return false;
|
if (path.isEmpty) return false;
|
||||||
|
if (!path.startsWith('/')) return false;
|
||||||
|
|
||||||
// Check if it's the container root (without Documents/, tmp/, etc.)
|
// Check if it's the container root (without Documents/, tmp/, etc.)
|
||||||
if (_iosContainerRootPattern.hasMatch(path)) {
|
if (_iosContainerRootPattern.hasMatch(path)) {
|
||||||
@@ -54,16 +63,64 @@ bool isValidIosWritablePath(String path) {
|
|||||||
|
|
||||||
/// Validates and potentially corrects an iOS path.
|
/// Validates and potentially corrects an iOS path.
|
||||||
/// Returns a valid Documents subdirectory path if the input is invalid.
|
/// Returns a valid Documents subdirectory path if the input is invalid.
|
||||||
Future<String> validateOrFixIosPath(String path, {String subfolder = 'SpotiFLAC'}) async {
|
Future<String> validateOrFixIosPath(
|
||||||
|
String path, {
|
||||||
|
String subfolder = 'SpotiFLAC',
|
||||||
|
}) async {
|
||||||
if (!Platform.isIOS) return path;
|
if (!Platform.isIOS) return path;
|
||||||
|
|
||||||
if (isValidIosWritablePath(path)) {
|
final trimmed = path.trim();
|
||||||
return path;
|
if (isValidIosWritablePath(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
final docDir = await getApplicationDocumentsDirectory();
|
||||||
|
final candidates = <String>[];
|
||||||
|
|
||||||
|
if (trimmed.isNotEmpty) {
|
||||||
|
candidates.add(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some pickers can return absolute iOS paths without the leading slash.
|
||||||
|
if (_iosContainerPathWithoutLeadingSlashPattern.hasMatch(trimmed)) {
|
||||||
|
candidates.add('/$trimmed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover legacy relative iOS path format:
|
||||||
|
// Data/Application/<UUID>/Documents/<subdir>
|
||||||
|
final legacyRelativeMatch = _iosLegacyRelativeDocumentsPattern.firstMatch(
|
||||||
|
trimmed,
|
||||||
|
);
|
||||||
|
if (legacyRelativeMatch != null) {
|
||||||
|
final suffix = (legacyRelativeMatch.group(1) ?? '').trim();
|
||||||
|
final normalizedSuffix = suffix.startsWith('/')
|
||||||
|
? suffix.substring(1)
|
||||||
|
: suffix;
|
||||||
|
candidates.add(
|
||||||
|
normalizedSuffix.isEmpty
|
||||||
|
? docDir.path
|
||||||
|
: '${docDir.path}/$normalizedSuffix',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic salvage for relative paths containing `Documents/...`.
|
||||||
|
if (!trimmed.startsWith('/')) {
|
||||||
|
final documentsMarker = 'Documents/';
|
||||||
|
final index = trimmed.indexOf(documentsMarker);
|
||||||
|
if (index >= 0) {
|
||||||
|
final suffix = trimmed.substring(index + documentsMarker.length).trim();
|
||||||
|
candidates.add(suffix.isEmpty ? docDir.path : '${docDir.path}/$suffix');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final candidate in candidates) {
|
||||||
|
if (isValidIosWritablePath(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to app Documents directory
|
// Fall back to app Documents directory
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final musicDir = Directory('${docDir.path}/$subfolder');
|
||||||
final musicDir = Directory('${dir.path}/$subfolder');
|
|
||||||
if (!await musicDir.exists()) {
|
if (!await musicDir.exists()) {
|
||||||
await musicDir.create(recursive: true);
|
await musicDir.create(recursive: true);
|
||||||
}
|
}
|
||||||
@@ -96,11 +153,20 @@ IosPathValidationResult validateIosPath(String path) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!path.startsWith('/')) {
|
||||||
|
return const IosPathValidationResult(
|
||||||
|
isValid: false,
|
||||||
|
errorReason:
|
||||||
|
'Invalid path format. Please choose a local folder from Files.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's the container root
|
// Check if it's the container root
|
||||||
if (_iosContainerRootPattern.hasMatch(path)) {
|
if (_iosContainerRootPattern.hasMatch(path)) {
|
||||||
return const IosPathValidationResult(
|
return const IosPathValidationResult(
|
||||||
isValid: false,
|
isValid: false,
|
||||||
errorReason: 'Cannot write to app container root. Please choose a subfolder like Documents.',
|
errorReason:
|
||||||
|
'Cannot write to app container root. Please choose a subfolder like Documents.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +176,8 @@ IosPathValidationResult validateIosPath(String path) {
|
|||||||
path.contains('com~apple~CloudDocs')) {
|
path.contains('com~apple~CloudDocs')) {
|
||||||
return const IosPathValidationResult(
|
return const IosPathValidationResult(
|
||||||
isValid: false,
|
isValid: false,
|
||||||
errorReason: 'iCloud Drive is not supported. Please choose a local folder.',
|
errorReason:
|
||||||
|
'iCloud Drive is not supported. Please choose a local folder.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +192,8 @@ IosPathValidationResult validateIosPath(String path) {
|
|||||||
if (remainingPath.isEmpty || remainingPath == '/') {
|
if (remainingPath.isEmpty || remainingPath == '/') {
|
||||||
return const IosPathValidationResult(
|
return const IosPathValidationResult(
|
||||||
isValid: false,
|
isValid: false,
|
||||||
errorReason: 'Cannot write to app container root. Please use the default folder or choose a different location.',
|
errorReason:
|
||||||
|
'Cannot write to app container root. Please use the default folder or choose a different location.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,27 @@ import 'package:spotiflac_android/constants/app_info.dart';
|
|||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
const int _maxLogMessageLength = 500;
|
const int _maxLogMessageLength = 500;
|
||||||
|
const String _redactedValue = '[REDACTED]';
|
||||||
|
|
||||||
|
final RegExp _authorizationBearerPattern = RegExp(
|
||||||
|
r'\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final RegExp _genericSensitiveKeyValuePattern = RegExp(
|
||||||
|
r'\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final RegExp _sensitiveQueryPattern = RegExp(
|
||||||
|
r'([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final RegExp _bearerTokenPattern = RegExp(
|
||||||
|
r'\bBearer\s+[A-Za-z0-9._~+/\-]+=*',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
|
String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
|
||||||
if (value.length <= maxLength) {
|
if (value.length <= maxLength) {
|
||||||
@@ -16,6 +37,33 @@ String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
|
|||||||
return '${value.substring(0, maxLength)}...[truncated]';
|
return '${value.substring(0, maxLength)}...[truncated]';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _redactSensitiveText(String value) {
|
||||||
|
var redacted = value;
|
||||||
|
|
||||||
|
redacted = redacted.replaceAllMapped(_authorizationBearerPattern, (_) {
|
||||||
|
return 'Authorization: Bearer $_redactedValue';
|
||||||
|
});
|
||||||
|
|
||||||
|
redacted = redacted.replaceAllMapped(_genericSensitiveKeyValuePattern, (
|
||||||
|
match,
|
||||||
|
) {
|
||||||
|
final key = match.group(1) ?? '';
|
||||||
|
final delimiter = match.group(2) ?? '=';
|
||||||
|
return '$key$delimiter$_redactedValue';
|
||||||
|
});
|
||||||
|
|
||||||
|
redacted = redacted.replaceAllMapped(_sensitiveQueryPattern, (match) {
|
||||||
|
final prefix = match.group(1) ?? '';
|
||||||
|
return '$prefix$_redactedValue';
|
||||||
|
});
|
||||||
|
|
||||||
|
redacted = redacted.replaceAllMapped(_bearerTokenPattern, (_) {
|
||||||
|
return 'Bearer $_redactedValue';
|
||||||
|
});
|
||||||
|
|
||||||
|
return redacted;
|
||||||
|
}
|
||||||
|
|
||||||
class LogEntry {
|
class LogEntry {
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
final String level;
|
final String level;
|
||||||
@@ -59,6 +107,7 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
final Queue<LogEntry> _entries = Queue<LogEntry>();
|
final Queue<LogEntry> _entries = Queue<LogEntry>();
|
||||||
Timer? _goLogTimer;
|
Timer? _goLogTimer;
|
||||||
int _lastGoLogIndex = 0;
|
int _lastGoLogIndex = 0;
|
||||||
|
bool _isFetchingGoLogs = false;
|
||||||
|
|
||||||
static bool _loggingEnabled = false;
|
static bool _loggingEnabled = false;
|
||||||
static bool get loggingEnabled => _loggingEnabled;
|
static bool get loggingEnabled => _loggingEnabled;
|
||||||
@@ -79,9 +128,11 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final sanitizedMessage = _truncateLogText(entry.message);
|
final sanitizedMessage = _truncateLogText(
|
||||||
|
_redactSensitiveText(entry.message),
|
||||||
|
);
|
||||||
final sanitizedError = entry.error != null
|
final sanitizedError = entry.error != null
|
||||||
? _truncateLogText(entry.error!)
|
? _truncateLogText(_redactSensitiveText(entry.error!))
|
||||||
: null;
|
: null;
|
||||||
final sanitizedEntry =
|
final sanitizedEntry =
|
||||||
(sanitizedMessage == entry.message && sanitizedError == entry.error)
|
(sanitizedMessage == entry.message && sanitizedError == entry.error)
|
||||||
@@ -105,13 +156,20 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
void startGoLogPolling() {
|
void startGoLogPolling() {
|
||||||
_goLogTimer?.cancel();
|
_goLogTimer?.cancel();
|
||||||
_goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
|
_goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
|
||||||
await _fetchGoLogs();
|
if (_isFetchingGoLogs) return;
|
||||||
|
_isFetchingGoLogs = true;
|
||||||
|
try {
|
||||||
|
await _fetchGoLogs();
|
||||||
|
} finally {
|
||||||
|
_isFetchingGoLogs = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void stopGoLogPolling() {
|
void stopGoLogPolling() {
|
||||||
_goLogTimer?.cancel();
|
_goLogTimer?.cancel();
|
||||||
_goLogTimer = null;
|
_goLogTimer = null;
|
||||||
|
_isFetchingGoLogs = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchGoLogs() async {
|
Future<void> _fetchGoLogs() async {
|
||||||
@@ -119,10 +177,15 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
||||||
final logs = result['logs'] as List<dynamic>? ?? [];
|
final logs = result['logs'] as List<dynamic>? ?? [];
|
||||||
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
|
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
|
||||||
|
final keepNonErrorLogs = _loggingEnabled;
|
||||||
|
|
||||||
for (final log in logs) {
|
for (final log in logs) {
|
||||||
final timestamp = log['timestamp'] as String? ?? '';
|
|
||||||
final level = log['level'] as String? ?? 'INFO';
|
final level = log['level'] as String? ?? 'INFO';
|
||||||
|
if (!keepNonErrorLogs && level != 'ERROR' && level != 'FATAL') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final timestamp = log['timestamp'] as String? ?? '';
|
||||||
final tag = log['tag'] as String? ?? 'Go';
|
final tag = log['tag'] as String? ?? 'Go';
|
||||||
final message = log['message'] as String? ?? '';
|
final message = log['message'] as String? ?? '';
|
||||||
|
|
||||||
@@ -211,7 +274,11 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
buffer.writeln(
|
buffer.writeln(
|
||||||
'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})',
|
'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})',
|
||||||
);
|
);
|
||||||
buffer.writeln('Device ID: ${android.id}');
|
buffer.writeln('Build ID: ${android.id}');
|
||||||
|
if (android.version.securityPatch != null &&
|
||||||
|
android.version.securityPatch!.isNotEmpty) {
|
||||||
|
buffer.writeln('Security Patch: ${android.version.securityPatch}');
|
||||||
|
}
|
||||||
buffer.writeln('Hardware: ${android.hardware}');
|
buffer.writeln('Hardware: ${android.hardware}');
|
||||||
buffer.writeln('Product: ${android.product}');
|
buffer.writeln('Product: ${android.product}');
|
||||||
buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}');
|
buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}');
|
||||||
@@ -308,12 +375,14 @@ class BufferedOutput extends LogOutput {
|
|||||||
void output(OutputEvent event) {
|
void output(OutputEvent event) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
for (final line in event.lines) {
|
for (final line in event.lines) {
|
||||||
debugPrint(_truncateLogText(line));
|
debugPrint(_truncateLogText(_redactSensitiveText(line)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final level = _levelToString(event.level);
|
final level = _levelToString(event.level);
|
||||||
final message = _truncateLogText(event.lines.join('\n'));
|
final message = _truncateLogText(
|
||||||
|
_redactSensitiveText(event.lines.join('\n')),
|
||||||
|
);
|
||||||
|
|
||||||
LogBuffer().add(
|
LogBuffer().add(
|
||||||
LogEntry(
|
LogEntry(
|
||||||
@@ -372,6 +441,10 @@ class AppLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _addToBuffer(String level, String message, {String? error}) {
|
void _addToBuffer(String level, String message, {String? error}) {
|
||||||
|
if (!LogBuffer.loggingEnabled && level != 'ERROR' && level != 'FATAL') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
LogBuffer().add(
|
LogBuffer().add(
|
||||||
LogEntry(
|
LogEntry(
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
@@ -412,7 +485,7 @@ class AppLogger {
|
|||||||
_addToBuffer('ERROR', message, error: error.toString());
|
_addToBuffer('ERROR', message, error: error.toString());
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[$_tag] ERROR: ${_truncateLogText(message)} | ${_truncateLogText(error.toString())}',
|
'[$_tag] ERROR: ${_truncateLogText(_redactSensitiveText(message))} | ${_truncateLogText(_redactSensitiveText(error.toString()))}',
|
||||||
);
|
);
|
||||||
if (stackTrace != null) {
|
if (stackTrace != null) {
|
||||||
debugPrint(stackTrace.toString());
|
debugPrint(stackTrace.toString());
|
||||||
|
|||||||
@@ -84,83 +84,6 @@ class _KofiPainter extends CustomPainter {
|
|||||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
class BmacIcon extends StatelessWidget {
|
|
||||||
final double size;
|
|
||||||
final Color color;
|
|
||||||
|
|
||||||
const BmacIcon({super.key, this.size = 22, this.color = Colors.black87});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return CustomPaint(
|
|
||||||
size: Size(size, size),
|
|
||||||
painter: _BmacPainter(color),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BmacPainter extends CustomPainter {
|
|
||||||
final Color color;
|
|
||||||
_BmacPainter(this.color);
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
final s = size.width;
|
|
||||||
final paint = Paint()
|
|
||||||
..color = color
|
|
||||||
..style = PaintingStyle.fill;
|
|
||||||
|
|
||||||
// Cup body (slightly tapered)
|
|
||||||
final cup = Path()
|
|
||||||
..moveTo(s * 0.15, s * 0.35)
|
|
||||||
..lineTo(s * 0.20, s * 0.82)
|
|
||||||
..quadraticBezierTo(s * 0.20, s * 0.90, s * 0.28, s * 0.90)
|
|
||||||
..lineTo(s * 0.56, s * 0.90)
|
|
||||||
..quadraticBezierTo(s * 0.64, s * 0.90, s * 0.64, s * 0.82)
|
|
||||||
..lineTo(s * 0.69, s * 0.35)
|
|
||||||
..close();
|
|
||||||
canvas.drawPath(cup, paint);
|
|
||||||
|
|
||||||
// Cup rim
|
|
||||||
final rim = RRect.fromRectAndRadius(
|
|
||||||
Rect.fromLTWH(s * 0.10, s * 0.30, s * 0.64, s * 0.10),
|
|
||||||
Radius.circular(s * 0.05),
|
|
||||||
);
|
|
||||||
canvas.drawRRect(rim, paint);
|
|
||||||
|
|
||||||
// Handle
|
|
||||||
final handlePaint = Paint()
|
|
||||||
..color = color
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = s * 0.07
|
|
||||||
..strokeCap = StrokeCap.round;
|
|
||||||
|
|
||||||
final handle = Path()
|
|
||||||
..moveTo(s * 0.69, s * 0.42)
|
|
||||||
..quadraticBezierTo(s * 0.90, s * 0.42, s * 0.90, s * 0.56)
|
|
||||||
..quadraticBezierTo(s * 0.90, s * 0.70, s * 0.69, s * 0.70);
|
|
||||||
canvas.drawPath(handle, handlePaint);
|
|
||||||
|
|
||||||
// Steam
|
|
||||||
final steamPaint = Paint()
|
|
||||||
..color = color.withValues(alpha: 0.5)
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = s * 0.04
|
|
||||||
..strokeCap = StrokeCap.round;
|
|
||||||
|
|
||||||
for (var i = 0; i < 3; i++) {
|
|
||||||
final sx = s * (0.26 + i * 0.14);
|
|
||||||
final steam = Path()
|
|
||||||
..moveTo(sx, s * 0.26)
|
|
||||||
..quadraticBezierTo(sx + s * 0.03, s * 0.18, sx, s * 0.10);
|
|
||||||
canvas.drawPath(steam, steamPaint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
||||||
}
|
|
||||||
|
|
||||||
class GitHubIcon extends StatelessWidget {
|
class GitHubIcon extends StatelessWidget {
|
||||||
final double size;
|
final double size;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ class BuiltInService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default quality options for built-in services (Tidal, Qobuz, YouTube)
|
/// Default quality options for built-in services (Tidal, Qobuz, Amazon, YouTube)
|
||||||
/// Note: Amazon is fallback-only and not shown in picker
|
|
||||||
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
|
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
|
||||||
const _builtInServices = [
|
const _builtInServices = [
|
||||||
BuiltInService(
|
BuiltInService(
|
||||||
@@ -44,6 +43,17 @@ const _builtInServices = [
|
|||||||
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
BuiltInService(
|
||||||
|
id: 'amazon',
|
||||||
|
label: 'Amazon',
|
||||||
|
qualityOptions: [
|
||||||
|
QualityOption(
|
||||||
|
id: 'LOSSLESS',
|
||||||
|
label: 'FLAC Best Available',
|
||||||
|
description: 'Amazon API delivers the best available lossless quality',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
BuiltInService(
|
BuiltInService(
|
||||||
id: 'youtube',
|
id: 'youtube',
|
||||||
label: 'YouTube',
|
label: 'YouTube',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.6.0+77
|
version: 3.6.7+81
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -42,7 +42,7 @@ dependencies:
|
|||||||
|
|
||||||
# Material Expressive 3 / Dynamic Color
|
# Material Expressive 3 / Dynamic Color
|
||||||
dynamic_color: ^1.7.0
|
dynamic_color: ^1.7.0
|
||||||
material_color_utilities: ^0.11.1
|
material_color_utilities: ">=0.11.1 <0.14.0"
|
||||||
|
|
||||||
# Permissions
|
# Permissions
|
||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
|
|||||||