Compare commits

...

58 Commits

Author SHA1 Message Date
zarzet 4e530ffbc3 chore: bump app version to v4.2.1 2026-04-04 21:48:19 +07:00
zarzet 14f6776fdc fix: remove stale audio service manifest entries causing crashes on some devices 2026-04-04 21:40:46 +07:00
zarzet da1c6e9171 fix: harden gomobile extension bindings and m4a cover retention 2026-04-04 21:30:11 +07:00
zarzet 9c3e934395 fix: preserve local convert format and library entries 2026-04-04 21:29:20 +07:00
zarzet 15d2c3b465 feat: enrich composer and track totals metadata 2026-04-04 18:50:05 +07:00
zarzet 8aaa6d5cbe fix: preserve embedded metadata details 2026-04-04 18:06:52 +07:00
zarzet 9158d0228d ci: pin iOS release builds to macOS 15 and Xcode 26.1.1 2026-04-04 15:53:46 +07:00
zarzet 2bbcda3320 fix: patch device_info_plus iOS build for older Xcode SDKs 2026-04-04 15:49:34 +07:00
zarzet a7622676dd feat: add additional search/metadata API with separate rate limiting 2026-04-04 13:54:55 +07:00
zarzet 5779f910a2 perf: incremental download queue lookup updates, async cover cleanup, and native JSON decoding on iOS
- Embed DownloadQueueLookup into DownloadQueueState; add updatedForIndices() for O(changed) incremental updates during frequent progress ticks instead of full O(n) rebuild
- downloadQueueLookupProvider now reads pre-computed lookup from state directly
- Replace sync file deletion in DownloadedEmbeddedCoverResolver with unawaited async cleanup to avoid blocking the main thread
- Parse JSON payloads on iOS native side (parseJsonPayload) so event sinks and method channel responses return native objects, avoiding redundant Dart-side JSON decode
- Use .cast<String, dynamic>() instead of Map.from() in _decodeMapResult for zero-copy map handling
2026-04-03 23:03:11 +07:00
zarzet 030f44a444 perf: reduce UI jank via memoization, compute isolates, SQL-backed playlist picker, and viewport-aware image caching
- Move explore JSON decode/encode to compute() isolate to avoid blocking main thread
- Memoize search sort results (artists/albums/playlists/tracks) in HomeTab; invalidate on new query
- Extract _DownloadedOrRemoteCover StatefulWidget with proper embedded-cover lifecycle management
- Replace O(playlists x tracks) in-memory playlist picker check with SQL loadPlaylistPickerSummaries query
- Add FutureProvider.family (libraryPlaylistPickerSummariesProvider) invalidated on all playlist mutations
- Memoize _buildQueueHistoryStats, localPathMatchKeys, and localSingleItems in QueueTab
- Add coverCacheWidthForViewport util; apply memCacheWidth/cacheWidth based on real DPR across all album/playlist/track screens
- Convert sync file ops in TrackMetadataScreen to async; use mtime+size as validation token
- Fetch Deezer album nb_tracks in parallel via fetchAlbumTrackCounts
2026-04-03 22:31:04 +07:00
zarzet 1248270fb4 fix: route Qobuz API calls through authenticated gateway to resolve 401 errors 2026-04-03 21:35:47 +07:00
zarzet 413e3b0686 refactor: consolidate FLAC/MP3/Opus metadata embedding into unified _embedMetadataToFile 2026-04-03 03:22:33 +07:00
zarzet ac711efadc feat: add skipLyrics manifest field for extensions to opt out of lyrics fetching 2026-04-03 03:14:51 +07:00
zarzet 59f2fe880a chore: remove redundant comments and update donor list 2026-04-03 02:21:40 +07:00
zarzet 355f2eba2a fix: resolve missing track/disc numbers from search downloads and suppress FFmpeg log noise
- Tidal: use actual API track_number/disc_number when request values are 0
  (fixes search/popular downloads having no track position in metadata)
- Extension enrichment: copy TrackNumber/DiscNumber back from enriched results
- Extension fallback download: add request metadata fallback for non-source
  extensions (Album, AlbumArtist, ReleaseDate, ISRC, TrackNumber, DiscNumber)
- FFmpeg: add -v error -hide_banner to all commands (embed, convert, CUE split)
  to eliminate banner, build config, and full metadata/lyrics dump in logcat
- ebur128: add framelog=quiet to suppress per-frame loudness measurements
  while keeping the summary needed for ReplayGain parsing
- Track metadata screen: separate embedded lyrics check from online fetch,
  show file-only state with manual online fetch button
2026-04-03 00:56:09 +07:00
zarzet f2f45fa31d fix: improve extension runtime safety, HTTP response URL, SongLink parsing, and recommended service for extensions
- Add 'url' field (final URL after redirects) to all extension HTTP responses and fix fetch polyfill to return final URL instead of original request URL
- Fix RunWithTimeout race condition: increase force-timeout from 1s to 60s to prevent concurrent VM access crashes, add nil guards
- Use lockReadyVM() for thread-safe VM access in GetPlaylistWithExtensionJSON and InvokeAction
- Handle mixed JSON types (string, null, array) in SongLink resolve API SongUrls field
- Fix recommended download service not showing for extension-based searches in download picker
2026-04-02 23:16:37 +07:00
zarzet 042937a8ed fix: resolve label and copyright from file metadata on info screen
The info screen was not reading label/copyright from the actual file metadata, so these fields were always empty for local library items and download history items that lacked them in-memory. Now _resolveAudioMetadata() extracts and displays them without requiring a manual save first.
2026-04-02 19:44:37 +07:00
zarzet 674e9af3d0 fix: validate ISRC in track metadata screen to prevent ID leakage
Sanitize the isrc getter to only return valid ISRC codes (12-char format per ISO 3901). Invalid values such as Spotify/Deezer/Tidal IDs that may leak into the ISRC field are now silently discarded, preventing them from being displayed or embedded into file tags.
2026-04-02 15:29:42 +07:00
zarzet 76d50fab3a fix: correct track/disc defaults, forward extension metadata, and fix service ID display
- Default track/disc number to 0 (unknown) instead of 1, letting the
  backend use the service-provided value or skip the field entirely
- Add releaseDate to ExploreItem so explore downloads carry release info
- Pass discNumber and releaseDate from extension album/playlist tracks
- Fix isDeezer detection using service field instead of substring match
- Add _displayServiceTrackId() to properly strip prefixes for all services
2026-04-02 15:13:11 +07:00
zarzet 81e25d7dab chore: bump version to 4.2.0 (build 121) 2026-04-02 03:20:56 +07:00
zarzet 26f26f792a feat: add ReplayGain scanning, APEv2 tag support, and fix metadata bugs
ReplayGain (track + album):
- Scan track loudness via FFmpeg ebur128 filter (-18 LUFS reference)
- Duration-weighted power-mean for album gain computation
- Support for FLAC (native Vorbis), MP3 (ID3v2 TXXX), Opus, M4A
- Album RG auto-finalizes when all album tracks complete
- Retryable gate: blocks finalization while failed/skipped items exist
- SAF support: lossy album RG writes via temp file + writeTempToSaf
- New embedReplayGain setting (off by default) with UI toggle

APEv2 tag support:
- Full APEv2 reader/writer with header+items+footer format
- Merge-based editing with override keys for explicit deletions
- Binary cover art embedding (Cover Art (Front) item)
- Library scanner support for .ape/.wv/.mpc files
- ReplayGain fields in APE read/write/edit pipeline

Bug fixes (26):
- setArtistComments wiping fields on empty string value
- APEv2 rewrite corrupting files with ID3v1 trailer
- APE edit replacing entire tag instead of merging
- ReplayGain lost on manual MP3/Opus/M4A metadata edit
- Editor metadata save losing custom tags (preserveMetadata)
- Album RG accumulator not cleaned on queue mutation
- Album gain using unweighted mean instead of power-mean
- writeAlbumReplayGainTags return value silently ignored
- SAF album RG writing to deleted temp path
- Cancelled tracks polluting album gain computation
- APE ReplayGain not wired end-to-end
- APE field deletion not working in merge
- APE cover edit was a no-op
- Album RG duplicate entries on retry
- APE apeKeysFromFields missing track/disc/lyrics mappings
- Album RG entries purged by removeItem before computation
- FFmpeg converters discarding empty metadata values
- _appendVorbisArtistEntries skipping empty value (null vs empty)
- Album RG write-back fails for SAF lossy files
- Album RG partial finalization on failed tracks
- FLAC ClearEmpty flag destroying tags on partial callers
- clearCompleted not retriggering album RG checks
- ReadFileMetadata MP3/Ogg missing label and copyright
- Cover embed on CUE split destroying split artist tags
- Album RG gain format inconsistent (missing + prefix)
- FLAC reader/editor missing tag aliases (ALBUMARTIST, LABEL, etc.)
- dart:math log shadowed by logger.dart export
2026-04-02 03:15:01 +07:00
zarzet 4dfa76b49e fix: remove deleted local library item from provider state after file deletion
When deleting a non-CUE local library track from the metadata screen,
only the file was removed but the library database entry and provider
state were left untouched, causing the track to persist in the library UI.
Now calls removeItem() on localLibraryProvider after deleteFile().
2026-04-01 21:04:42 +07:00
zarzet f511f30ad0 feat: add resolve API with SongLink fallback, fix multi-artist tags (#288), and cleanup
Resolve API (api.zarz.moe):
- Refactor songlink.go: Spotify URLs use resolve API, non-Spotify uses SongLink API
- Add SongLink fallback when resolve API fails for Spotify (two-layer resilience)
- Remove dead code: page parser, XOR-obfuscated keys, legacy helpers

Multi-artist tag fix (#288):
- Add RewriteSplitArtistTags() in Go to rewrite ARTIST/ALBUMARTIST as split Vorbis comments
- Wire method channel handler in Android (MainActivity.kt) and iOS (AppDelegate.swift)
- Add PlatformBridge.rewriteSplitArtistTags() in Dart
- Call native FLAC rewriter after FFmpeg embed when split_vorbis mode is active
- Extract deezerTrackArtistDisplay() helper to use Contributors in album/playlist tracks

Code cleanup:
- Remove unused imports, dead code, and redundant comments across Go and Dart
- Fix build: remove stale getQobuzDebugKey() reference in deezer_download.go
2026-04-01 02:49:19 +07:00
zarzet a1aa1319ce feat: add separate filename format for singles and EPs (#271)
Add singleFilenameFormat setting so singles/EPs can use a different filename template than albums. The format editor is reused with custom title/description. Dart selects the correct format based on track.isSingle before passing to Go, so no backend changes needed. Also fix isSingle getter to include all EPs regardless of totalTracks. Closes #271
2026-03-31 18:55:48 +07:00
zarzet c936bd7dd0 fix: match system navigation bar color with app theme
Set systemNavigationBarColor to surfaceContainer (matching the in-app
NavigationBar) via AppBarTheme.systemOverlayStyle. Handles light, dark,
AMOLED and dynamic color schemes automatically.

Closes zarzet/SpotiFLAC-Mobile#284
2026-03-31 18:36:28 +07:00
zarzet 3a60ea2f4e feat: add field selection dialog for bulk re-enrich metadata
Add a bottom sheet dialog that lets users choose which metadata field
groups to update during bulk re-enrich (cover, lyrics, album/album
artist, track/disc number, date/ISRC, genre/label/copyright).

Backend (Go):
- Filter FLAC Metadata struct and FFmpeg metadata map by selected
  update_fields so non-selected groups preserve existing file values
- Guard Deezer extended metadata fetch with shouldUpdateField(extra)
- Title/Artist are never overwritten by re-enrich (search keys only)
- enrichedMeta response only includes selected field groups

Frontend (Dart):
- New re_enrich_field_dialog.dart bottom sheet with checkboxes
- FFmpegService embed methods gain preserveMetadata param that uses
  -map_metadata 0 instead of -1 to preserve non-selected tags
- Hide selection overlay/bar before showing dialog, restore on cancel
- Fix setState-after-dispose guard in cancel branches

Cleanup:
- Remove dead code in library_tracks_folder_screen.dart
- Fix use_build_context_synchronously in main_shell.dart
- Suppress false-positive use_null_aware_elements lints
- Update l10n label from 'Title, Artist, Album' to 'Album, Album Artist'
2026-03-31 18:21:45 +07:00
zarzet 7dba938299 fix: prefer local file for cover/lyrics save and update build dependencies
- Cover art: extract from downloaded file first, fall back to URL download
- Lyrics: check embedded lyrics/sidecar LRC before fetching online
- Add audioFilePath param to FetchAndSaveLyrics (Go, Kotlin, Swift, Dart)
- Handle SAF content:// URIs for lyrics extraction in Kotlin bridge
- Update Go 1.25.7 -> 1.25.8, Gradle 9.3.1 -> 9.4.1, Kotlin 2.2.21 -> 2.3.20
- Update NDK r27d -> r28b, Flutter FVM 3.41.4 -> 3.41.5
- Upgrade all Flutter and Go module dependencies to latest
2026-03-31 17:25:30 +07:00
zarzet 93e77aeb84 refactor: remove legacy API clients, Yoinkify fallback, and unused lyrics provider
- Delete dead metadata client and extract shared types to metadata_types.go
- Remove Yoinkify download fallback from Deezer, use MusicDL only
- Clean up retired settings fields and metadataSource
- Remove dead l10n keys for retired provider
- Add migration to strip retired provider from existing users' lyrics config
2026-03-30 23:26:37 +07:00
zarzet dd750b95ca chore: bump version to 4.1.3 (build 120) 2026-03-30 18:25:42 +07:00
zarzet e42e44f28b fix: Samsung SAF library scan, Qobuz album cover, M4A metadata save and log improvements
- Fix M4A/ALAC scan silently failing on Samsung by adding proper fallback
  to scanFromFilename when ReadM4ATags fails (consistent with MP3/FLAC/Ogg)
- Propagate displayNameHint to all format scanners so fd numbers (214, 207)
  no longer appear as track names when /proc/self/fd/ paths are used
- Cache /proc/self/fd/ readability in Kotlin to skip failed attempts after
  first failure, reducing error log noise and improving scan speed on Samsung
- Fix Qobuz download returning wrong album cover when track exists on
  multiple albums by preferring req.CoverURL over API default
- Fix FFmpeg M4A metadata save failing with 'codec not currently supported
  in container' by forcing mp4 muxer instead of ipod when cover art present
- Clean up FLAC SAF temp file after metadata write-back (was leaking)
- Update LRC lyrics tag to credit Paxsenix API
- Remove log message truncation, defer to UI preview truncation instead
2026-03-30 18:12:20 +07:00
zarzet 67daefdf60 feat: add artist tag mode setting with split Vorbis support and improve library scan progress
- Add artist_tag_mode setting (joined / split_vorbis) for FLAC/Opus multi-artist tags
- Split 'Artist A, Artist B' into separate ARTIST= Vorbis comments when split mode is enabled
- Join repeated ARTIST/ALBUMARTIST Vorbis comments when reading metadata
- Propagate artistTagMode through download pipeline, re-enrich, and metadata editor
- Improve library scan progress: separate polling intervals, finalizing state, indeterminate progress
- Add initial progress snapshot on library scan stream connect
- Use req.ArtistName consistently for Qobuz downloads instead of track.Performer.Name
- Add l10n keys for artist tag mode, library files unit, and scan finalizing status
2026-03-30 12:38:42 +07:00
zarzet fabaf0a3ff feat: add stable cover cache keys, Qobuz album-search fallback, metadata filters and extended sort options
- Introduce coverCacheKey parameter through Go backend and Kotlin bridge for stable SAF cover caching
- Add MetadataFromFilename flag to skip filename-only metadata and retry via temp-file copy
- Add Qobuz album-search fallback between API search and store scraping
- Extract buildReEnrichFFmpegMetadata to skip empty metadata fields
- Add metadata completeness filter (complete, missing year/genre/album artist)
- Add sort modes: artist, album, release date, genre (asc/desc)
- Prune stale library cover cache files after full scan
- Skip empty values and zero track/disc numbers in FFmpeg metadata
- Add new l10n keys for metadata filter and sort options
2026-03-30 11:41:11 +07:00
zarzet fb90c73f42 fix: use Tidal quality options as fallback instead of DEFAULT for extensions 2026-03-29 18:57:13 +07:00
zarzet c6cf65f075 fix: normalize DEFAULT quality to prevent Tidal/Qobuz API failures 2026-03-29 18:49:57 +07:00
zarzet 25de009ebc feat: replace batch operation snackbars with progress dialog
Add reusable BatchProgressDialog widget with circular/linear progress
indicators, cancel support, and track detail display. Uses ValueNotifier
pattern to communicate progress from caller to dialog across navigator
routes.
2026-03-29 18:04:38 +07:00
zarzet 8918d74bb5 refactor: extract and improve ReEnrich track selection with scoring-based matching 2026-03-29 17:45:51 +07:00
zarzet f9de8d45d9 fix: add attached_pic disposition to ALAC cover art embedding 2026-03-29 17:41:43 +07:00
zarzet 48eef0853d i18n: extract hardcoded strings into l10n keys
Move hardcoded UI strings across multiple screens and the notification
service into ARB-backed l10n keys so they can be translated via Crowdin.
Adds 62 new keys covering sort labels, dialog copy, metadata error
snackbars, folder-picker errors, home-tab error states, extensions home
feed selector, and all notification titles/bodies. NotificationService
now caches an AppLocalizations instance (injected from MainShell via
didChangeDependencies) and falls back to English literals when no locale
is available.
2026-03-29 17:02:12 +07:00
zarzet fc70a912bf refactor: route spotify URLs through extensions 2026-03-29 16:35:16 +07:00
zarzet cd3e5b4b28 chore: bump version to 4.1.2+119 2026-03-29 15:40:24 +07:00
zarzet 482ca82eb4 feat: improve track matching 2026-03-29 15:34:44 +07:00
zarzet 6d87ae5484 feat: add haptic feedback when swiping library tabs 2026-03-29 01:56:22 +07:00
zarzet bd3e2b999b feat: add play button to playlist/library track tiles
Show a play IconButton (matching local album style) next to the
more-options button when a track has a local file available.
Uses PlaybackController.playTrackList to resolve and open the file.
2026-03-29 01:54:27 +07:00
zarzet 186196e12b fix: use START_NOT_STICKY for DownloadService to prevent auto-restart
Prevents Android from automatically recreating the download service
after it is killed, avoiding duplicate or orphaned download processes.
2026-03-29 01:37:24 +07:00
zarzet bd73eb292d chore: bump version to 4.1.1+118 2026-03-27 22:29:16 +07:00
zarzet 8ee2919934 feat: track byte-level download progress for extension downloads
Pass active download item ID through extension download pipeline so
fileDownload can report bytes received/total via ItemProgressWriter.
Add bytesTotal field to DownloadItem model and show X/Y MB progress
in queue tab when total size is known.
2026-03-27 21:58:01 +07:00
zarzet f29177216d refactor: enable strict analysis options and fix type safety across codebase
Enable strict-casts, strict-inference, and strict-raw-types in
analysis_options.yaml. Add custom_lint with riverpod_lint. Fix all
resulting type warnings with explicit type parameters and safer casts.

Also improves APK update checker to detect device ABIs for correct
variant selection and fixes Deezer artist name parsing edge case.
2026-03-27 19:28:42 +07:00
zarzet 18d3612674 fix(ui): skip popular section in artist skeleton for providers without top tracks 2026-03-27 13:27:07 +07:00
zarzet f7c0e417d7 refactor: unexport extension store types and methods (package-internal only) 2026-03-27 04:50:40 +07:00
zarzet 3fd13e9930 fix(ui): match GridSkeleton cover height with actual album cards 2026-03-27 04:39:29 +07:00
zarzet 0b20cb895e fix: conditionally show cover header in artist skeleton and add showCoverHeader param to ArtistScreenSkeleton 2026-03-27 04:35:22 +07:00
zarzet 8979210804 fix: null check crash in SpectrogramView when spectrum loaded from PNG cache 2026-03-27 04:24:19 +07:00
zarzet e9b24712c5 feat: cache spectrogram as PNG for instant loading on subsequent views 2026-03-27 04:21:11 +07:00
zarzet 3d6e5615fa Revert "docs: move badges below screenshots in README"
This reverts commit 198ed5ce6f.
2026-03-27 03:56:57 +07:00
zarzet fc7220b572 docs: update VirusTotal hash for v4.1.0 2026-03-27 03:54:31 +07:00
zarzet 198ed5ce6f docs: move badges below screenshots in README 2026-03-27 03:53:31 +07:00
zarzet b48462a945 fix: add artist_album_flat case to SAF relative output dir builder 2026-03-26 18:31:00 +07:00
159 changed files with 14967 additions and 5788 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"flutter": "3.41.4" "flutter": "3.41.5"
} }
+1 -1
View File
@@ -4,5 +4,5 @@ contact_links:
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
about: Check the README for setup instructions and FAQ about: Check the README for setup instructions and FAQ
- name: Extension Development Guide - name: Extension Development Guide
url: https://zarz.moe/docs url: https://spotiflac.zarz.moe/docs
about: Documentation for building SpotiFLAC extensions about: Documentation for building SpotiFLAC extensions
+11 -6
View File
@@ -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.7" go-version: "1.25.8"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds # Cache Gradle for faster builds
@@ -93,12 +93,12 @@ jobs:
# Accept licenses # Accept licenses
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
# Install NDK r27d LTS (required for 16KB page size support on Android 15+) # Install NDK r29 (supports 16KB page size for Android 15+)
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16) # Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0" $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-36" "build-tools;36.0.0"
# Set NDK path # Set NDK path
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
- name: Install gomobile - name: Install gomobile
run: | run: |
@@ -164,17 +164,22 @@ jobs:
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
build-ios: build-ios:
runs-on: macos-latest runs-on: macos-15
needs: get-version # Only depends on version, NOT android build! needs: get-version # Only depends on version, NOT android build!
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Select Xcode 26.1.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.1.app
xcodebuild -version
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version: "1.25.7" go-version: "1.25.8"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache CocoaPods # Cache CocoaPods
+1
View File
@@ -77,6 +77,7 @@ flutter_*.log
# Development tools # Development tools
tool/ tool/
.claude/settings.local.json .claude/settings.local.json
.playwright-mcp/
# FVM Version Cache # FVM Version Cache
.fvm/ .fvm/
+14 -1
View File
@@ -17,7 +17,7 @@
<div align="center"> <div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487) [![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac) [![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
@@ -170,5 +170,18 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | | [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | | | [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
---
## Disclaimer
This repository and its contents are provided strictly for educational and research purposes. The software is provided "as-is" without warranty of any kind, express or implied, as stated in the [MIT License](LICENSE).
- No copyrighted content is hosted, stored, mirrored, or distributed by this repository.
- Users must ensure that their use of this software is properly authorized and complies with all applicable laws, regulations, and third-party terms of service.
- This software is provided free of charge by the maintainer. If you paid a third party for access to this software in its original form from this repository, you may have been misled or scammed. Any redistribution or commercial use by third parties must comply with the terms of the repository license. No affiliation, endorsement, or support by the maintainer is implied unless explicitly stated in writing.
- SpotiFLAC Mobile is an independent project. It is not affiliated with, endorsed by, or connected to any other project or version on other platforms that may share a similar name. The maintainer of this repository has no control over or responsibility for third-party projects.
- The author(s) disclaim all liability for any direct, indirect, incidental, or consequential damages arising from the use or misuse of this software. Users assume all risk associated with its use.
- If you are a copyright holder or authorized representative and believe this repository infringes upon your rights, please contact the maintainer with sufficient detail (including relevant URLs and proof of ownership). The matter will be promptly investigated and appropriate action will be taken, which may include removal of the referenced material.
> [!TIP] > [!TIP]
> **Star the repo** to get notified about all new releases directly from GitHub. > **Star the repo** to get notified about all new releases directly from GitHub.
+20
View File
@@ -9,6 +9,19 @@
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- .dart_tool/**
- lib/**/*.g.dart
- lib/l10n/*.dart
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
plugins:
- custom_lint
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # section below to disable rules from the `package:flutter_lints/flutter.yaml`
@@ -23,6 +36,13 @@ linter:
rules: rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
avoid_dynamic_calls: true
cancel_subscriptions: true
close_sinks: true
custom_lint:
rules:
- avoid_public_notifier_properties
# Additional information about this file can be found at # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options
+15
View File
@@ -57,6 +57,18 @@ android {
} }
buildTypes { buildTypes {
getByName("debug") {
ndk {
debugSymbolLevel = "FULL"
}
}
getByName("profile") {
ndk {
debugSymbolLevel = "FULL"
}
}
release { release {
// For local builds: use release signing if key.properties exists // For local builds: use release signing if key.properties exists
// For CI builds: APK is signed by GitHub Action after build // For CI builds: APK is signed by GitHub Action after build
@@ -71,6 +83,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
ndk {
debugSymbolLevel = "FULL"
}
} }
} }
-18
View File
@@ -94,24 +94,6 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<!-- Audio playback service for media notification / background audio -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- flutter_local_notifications receivers --> <!-- flutter_local_notifications receivers -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" /> <receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"> <receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
@@ -104,7 +104,7 @@ class DownloadService : Service() {
updateNotification(progress, total) updateNotification(progress, total)
} }
} }
return START_STICKY return START_NOT_STICKY
} }
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -40,7 +40,8 @@ class MainActivity: FlutterFragmentActivity() {
"com.zarz.spotiflac/download_progress_stream" "com.zarz.spotiflac/download_progress_stream"
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
"com.zarz.spotiflac/library_scan_progress_stream" "com.zarz.spotiflac/library_scan_progress_stream"
private val STREAM_POLLING_INTERVAL_MS = 1200L private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L
private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L
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()
@@ -55,6 +56,8 @@ class MainActivity: FlutterFragmentActivity() {
private var flutterBackCallback: OnBackPressedCallback? = null private var flutterBackCallback: OnBackPressedCallback? = null
@Volatile private var safScanCancel = false @Volatile private var safScanCancel = false
@Volatile private var safScanActive = false @Volatile private var safScanActive = false
/** Tri-state: null = untested, true = works, false = fails (Samsung SELinux). */
@Volatile private var procSelfFdReadable: Boolean? = null
private val safTreeLauncher = registerForActivityResult( private val safTreeLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { activityResult -> ) { activityResult ->
@@ -160,10 +163,6 @@ class MainActivity: FlutterFragmentActivity() {
"sm-t225", "sm-t225",
"hammerhead", "hammerhead",
) )
/**
* Check if device should use Skia instead of Impeller.
* Returns true for devices with old/problematic GPUs or old Android versions.
*/
private fun shouldDisableImpeller(): Boolean { private fun shouldDisableImpeller(): Boolean {
val hardware = Build.HARDWARE.lowercase(Locale.ROOT) val hardware = Build.HARDWARE.lowercase(Locale.ROOT)
val board = Build.BOARD.lowercase(Locale.ROOT) val board = Build.BOARD.lowercase(Locale.ROOT)
@@ -212,7 +211,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
/** /**
* Try to get GPU renderer string.
* Note: This may return empty on some devices before OpenGL context is created. * Note: This may return empty on some devices before OpenGL context is created.
*/ */
private fun getGpuRenderer(): String { private fun getGpuRenderer(): String {
@@ -304,6 +302,7 @@ class MainActivity: FlutterFragmentActivity() {
".mp3" -> "audio/mpeg" ".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg" ".opus" -> "audio/ogg"
".flac" -> "audio/flac" ".flac" -> "audio/flac"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream" else -> "application/octet-stream"
} }
} }
@@ -373,6 +372,8 @@ class MainActivity: FlutterFragmentActivity() {
synchronized(safScanLock) { synchronized(safScanLock) {
safScanProgress = SafScanProgress() safScanProgress = SafScanProgress()
} }
// Allow re-probing /proc/self/fd readability on every new scan session.
procSelfFdReadable = null
} }
private fun updateSafScanProgress(block: (SafScanProgress) -> Unit) { private fun updateSafScanProgress(block: (SafScanProgress) -> Unit) {
@@ -453,7 +454,7 @@ class MainActivity: FlutterFragmentActivity() {
"Download progress stream poll failed: ${e.message}", "Download progress stream poll failed: ${e.message}",
) )
} }
delay(STREAM_POLLING_INTERVAL_MS) delay(DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS)
} }
} }
} }
@@ -470,6 +471,18 @@ class MainActivity: FlutterFragmentActivity() {
libraryScanProgressEventSink = sink libraryScanProgressEventSink = sink
lastLibraryScanProgressPayload = null lastLibraryScanProgressPayload = null
libraryScanProgressStreamJob = scope.launch { libraryScanProgressStreamJob = scope.launch {
try {
val initialPayload = withContext(Dispatchers.IO) {
readLibraryScanProgressJsonForStream()
}
lastLibraryScanProgressPayload = initialPayload
sink.success(parseJsonPayload(initialPayload))
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"Library scan progress initial poll failed: ${e.message}",
)
}
while (isActive && libraryScanProgressEventSink === sink) { while (isActive && libraryScanProgressEventSink === sink) {
try { try {
val payload = withContext(Dispatchers.IO) { val payload = withContext(Dispatchers.IO) {
@@ -485,7 +498,7 @@ class MainActivity: FlutterFragmentActivity() {
"Library scan progress stream poll failed: ${e.message}", "Library scan progress stream poll failed: ${e.message}",
) )
} }
delay(STREAM_POLLING_INTERVAL_MS) delay(LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS)
} }
} }
} }
@@ -776,29 +789,59 @@ class MainActivity: FlutterFragmentActivity() {
return if (ext.isNullOrBlank()) "audio" else "audio$ext" return if (ext.isNullOrBlank()) "audio" else "audio$ext"
} }
private fun buildLibraryCoverCacheKey(stablePath: String, lastModified: Long): String {
val normalizedPath = stablePath.trim()
if (normalizedPath.isEmpty()) return ""
return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath
}
private fun readAudioMetadataFromUri( private fun readAudioMetadataFromUri(
uri: Uri, uri: Uri,
displayNameHint: String? = null, displayNameHint: String? = null,
fallbackExt: String? = null, fallbackExt: String? = null,
coverCacheKey: String = "",
): JSONObject? { ): JSONObject? {
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt) val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
try { // Skip /proc/self/fd/ attempt when known to fail (e.g. Samsung SELinux).
contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> if (procSelfFdReadable != false) {
val directPath = "/proc/self/fd/${pfd.fd}" try {
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(directPath, displayName) contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
if (metadataJson.isNotBlank()) { val directPath = "/proc/self/fd/${pfd.fd}"
val obj = JSONObject(metadataJson) val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
if (!obj.has("error")) { directPath,
return obj displayName,
coverCacheKey,
)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
val filenameFallback = obj.optBoolean("metadataFromFilename", false)
if (!obj.has("error") && !filenameFallback) {
procSelfFdReadable = true
return obj
}
// Go could not read real metadata from the fd path
// remember so we skip the attempt for remaining files.
if (procSelfFdReadable == null) {
procSelfFdReadable = false
android.util.Log.d(
"SpotiFLAC",
"Direct /proc/self/fd read not usable on this device, " +
"using temp-file fallback for remaining files",
)
}
} }
} }
} catch (e: Exception) {
if (procSelfFdReadable == null) {
procSelfFdReadable = false
android.util.Log.d(
"SpotiFLAC",
"Direct /proc/self/fd read not usable on this device, " +
"using temp-file fallback for remaining files",
)
}
} }
} catch (e: Exception) {
android.util.Log.d(
"SpotiFLAC",
"Direct SAF metadata read fallback for $uri: ${e.message}",
)
} }
val tempPath = try { val tempPath = try {
@@ -812,7 +855,11 @@ class MainActivity: FlutterFragmentActivity() {
} ?: return null } ?: return null
try { try {
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(tempPath, displayName) val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
tempPath,
displayName,
coverCacheKey,
)
if (metadataJson.isBlank()) return null if (metadataJson.isBlank()) return null
val obj = JSONObject(metadataJson) val obj = JSONObject(metadataJson)
return if (obj.has("error")) null else obj return if (obj.has("error")) null else obj
@@ -1189,6 +1236,11 @@ class MainActivity: FlutterFragmentActivity() {
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueDoc.lastModified() }
val coverCacheKey = buildLibraryCoverCacheKey(
audioDoc.uri.toString(),
audioLastModified,
)
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
if (tempAudioPath == null) { if (tempAudioPath == null) {
@@ -1207,11 +1259,12 @@ class MainActivity: FlutterFragmentActivity() {
val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L } val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
val cueResultsJson = Gobackend.scanCueSheetForLibrary( val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey(
tempCuePath, tempCuePath,
tempDir, tempDir,
cueDoc.uri.toString(), cueDoc.uri.toString(),
cueLastModified cueLastModified,
coverCacheKey,
) )
val cueArray = JSONArray(cueResultsJson) val cueArray = JSONArray(cueResultsJson)
@@ -1263,13 +1316,19 @@ class MainActivity: FlutterFragmentActivity() {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt) val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
val stableUri = doc.uri.toString()
val coverCacheKey = buildLibraryCoverCacheKey(stableUri, lastModified)
val metadataObj = readAudioMetadataFromUri(
doc.uri,
name,
fallbackExt,
coverCacheKey,
)
if (metadataObj == null) { if (metadataObj == null) {
errors++ errors++
} else { } else {
try { try {
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri)) metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri) metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", lastModified) metadataObj.put("fileModTime", lastModified)
@@ -1537,6 +1596,11 @@ class MainActivity: FlutterFragmentActivity() {
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueLastModified }
val coverCacheKey = buildLibraryCoverCacheKey(
audioDoc.uri.toString(),
audioLastModified,
)
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
if (tempAudioPath == null) { if (tempAudioPath == null) {
@@ -1553,11 +1617,12 @@ class MainActivity: FlutterFragmentActivity() {
tempAudioPath = renamedAudio.absolutePath tempAudioPath = renamedAudio.absolutePath
} }
val cueResultsJson = Gobackend.scanCueSheetForLibrary( val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey(
tempCuePath, tempCuePath,
tempDir, tempDir,
cueDoc.uri.toString(), cueDoc.uri.toString(),
cueLastModified cueLastModified,
coverCacheKey,
) )
val cueArray = JSONArray(cueResultsJson) val cueArray = JSONArray(cueResultsJson)
@@ -1654,13 +1719,19 @@ class MainActivity: FlutterFragmentActivity() {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt) val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
val stableUri = doc.uri.toString()
val coverCacheKey = buildLibraryCoverCacheKey(stableUri, safeLastModified)
val metadataObj = readAudioMetadataFromUri(
doc.uri,
name,
fallbackExt,
coverCacheKey,
)
if (metadataObj == null) { if (metadataObj == null) {
errors++ errors++
} else { } else {
try { try {
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri)) metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri) metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", safeLastModified) metadataObj.put("fileModTime", safeLastModified)
@@ -1940,13 +2011,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(null) result.success(null)
} }
"parseSpotifyUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseSpotifyURL(url)
}
result.success(response)
}
"checkAvailability" -> { "checkAvailability" -> {
val spotifyId = call.argument<String>("spotify_id") ?: "" val spotifyId = call.argument<String>("spotify_id") ?: ""
val isrc = call.argument<String>("isrc") ?: "" val isrc = call.argument<String>("isrc") ?: ""
@@ -2315,6 +2379,41 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
"rewriteSplitArtistTags" -> {
val filePath = call.argument<String>("file_path") ?: ""
val artist = call.argument<String>("artist") ?: ""
val albumArtist = call.argument<String>("album_artist") ?: ""
val response = withContext(Dispatchers.IO) {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri, ".flac")
?: return@withContext errorJson("Failed to copy SAF file to temp")
try {
val raw = Gobackend.rewriteSplitArtistTagsExport(tempPath, artist, albumArtist)
val obj = JSONObject(raw)
if (!obj.optBoolean("success", false)) {
return@withContext raw
}
if (!writeUriFromPath(uri, tempPath)) {
return@withContext errorJson("Failed to write rewritten tags back to SAF file")
}
obj.put("file_path", filePath)
obj.toString()
} catch (e: Exception) {
errorJson("Failed to rewrite split artist tags in SAF file: ${e.message}")
} finally {
try {
File(tempPath).delete()
} catch (_: Exception) {}
}
} else {
Gobackend.rewriteSplitArtistTagsExport(filePath, artist, albumArtist)
}
}
result.success(response)
}
"cleanupConnections" -> { "cleanupConnections" -> {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Gobackend.cleanupConnections() Gobackend.cleanupConnections()
@@ -2364,11 +2463,13 @@ class MainActivity: FlutterFragmentActivity() {
return@withContext obj.toString() return@withContext obj.toString()
// Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf // Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf
} }
// FLAC: Go wrote directly to temp, copy back now // FLAC: Go wrote directly to temp, copy back now
if (!writeUriFromPath(uri, tempPath)) { if (!writeUriFromPath(uri, tempPath)) {
return@withContext """{"error":"Failed to write metadata back to SAF file"}""" try { File(tempPath).delete() } catch (_: Exception) {}
} return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
raw }
try { File(tempPath).delete() } catch (_: Exception) {}
raw
} catch (e: Exception) { } catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {} try { File(tempPath).delete() } catch (_: Exception) {}
throw e throw e
@@ -2445,12 +2546,27 @@ class MainActivity: FlutterFragmentActivity() {
val spotifyId = call.argument<String>("spotify_id") ?: "" val spotifyId = call.argument<String>("spotify_id") ?: ""
val durationMs = call.argument<Number>("duration_ms")?.toLong() ?: 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 rawAudioFilePath = call.argument<String>("audio_file_path") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
var safAudioTemp: String? = null
try { try {
Gobackend.fetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath) // Resolve SAF content:// URI to a temp file the Go backend can read
val audioFilePath = if (rawAudioFilePath.startsWith("content://")) {
val uri = Uri.parse(rawAudioFilePath)
val tempPath = copyUriToTemp(uri)
safAudioTemp = tempPath
tempPath ?: ""
} else {
rawAudioFilePath
}
Gobackend.fetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, audioFilePath)
"""{"success":true}""" """{"success":true}"""
} catch (e: Exception) { } catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" """{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
} finally {
if (safAudioTemp != null) {
try { File(safAudioTemp).delete() } catch (_: Exception) {}
}
} }
} }
result.success(response) result.success(response)
@@ -2710,13 +2826,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
"getSpotifyMetadataWithFallback" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
}
result.success(response)
}
"checkAvailabilityFromDeezerID" -> { "checkAvailabilityFromDeezerID" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: "" val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
+1 -1
View File
@@ -20,7 +20,7 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.21" apply false id("org.jetbrains.kotlin.android") version "2.3.20" apply false
} }
include(":app") include(":app")
+611
View File
@@ -0,0 +1,611 @@
package gobackend
import (
"encoding/binary"
"fmt"
"io"
"os"
"strings"
)
// APEv2 tag format constants.
const (
apeTagPreamble = "APETAGEX"
apeTagHeaderSize = 32
apeTagVersion2 = 2000
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
apeTagFlagReadOnly = 1 << 0
// Item flags: bits 1-2 encode content type
apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text
apeItemFlagBinary = 1 << 1 // 01: binary data
apeItemFlagLink = 2 << 1 // 10: external link
)
// APETagItem represents a single key-value item in an APEv2 tag.
type APETagItem struct {
Key string
Value string
Flags uint32
}
// APETag represents a complete APEv2 tag block.
type APETag struct {
Version uint32
Items []APETagItem
ReadOnly bool
}
// ReadAPETags reads APEv2 tags from a file.
// APEv2 tags are typically appended at the end of the file.
// The layout is: [audio data] [APEv2 header (optional)] [items...] [APEv2 footer]
// We locate the footer first (last 32 bytes), then read the tag block.
func ReadAPETags(filePath string) (*APETag, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("failed to stat file: %w", err)
}
fileSize := fi.Size()
if fileSize < apeTagHeaderSize {
return nil, fmt.Errorf("file too small for APE tag")
}
// Try to find APE tag footer at the end of file.
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
if err == nil {
return tag, nil
}
// Retry: skip ID3v1 tag (128 bytes) if present
if fileSize > apeTagHeaderSize+128 {
tag, err = readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize-128)
if err == nil {
return tag, nil
}
}
return nil, fmt.Errorf("no APEv2 tag found")
}
func readAPETagAtOffset(f *os.File, fileSize, footerOffset int64) (*APETag, error) {
if footerOffset < 0 || footerOffset+apeTagHeaderSize > fileSize {
return nil, fmt.Errorf("invalid footer offset")
}
footer := make([]byte, apeTagHeaderSize)
if _, err := f.ReadAt(footer, footerOffset); err != nil {
return nil, fmt.Errorf("failed to read APE footer: %w", err)
}
if string(footer[0:8]) != apeTagPreamble {
return nil, fmt.Errorf("APE preamble not found")
}
version := binary.LittleEndian.Uint32(footer[8:12])
tagSize := binary.LittleEndian.Uint32(footer[12:16]) // size of items + footer (32 bytes)
itemCount := binary.LittleEndian.Uint32(footer[16:20])
flags := binary.LittleEndian.Uint32(footer[20:24])
if version != apeTagVersion2 && version != 1000 {
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
}
if tagSize < apeTagHeaderSize {
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
}
if itemCount > 1000 {
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
}
// This should be the footer (bit 29 clear)
isHeader := (flags & apeTagFlagHeader) != 0
if isHeader {
return nil, fmt.Errorf("expected APE footer but found header")
}
// tagSize includes items + footer (32 bytes), but NOT the header.
itemsSize := int64(tagSize) - apeTagHeaderSize
if itemsSize < 0 {
return nil, fmt.Errorf("invalid APE tag: items size negative")
}
itemsOffset := footerOffset - itemsSize
if itemsOffset < 0 {
return nil, fmt.Errorf("APE tag items extend before file start")
}
itemsData := make([]byte, itemsSize)
if _, err := f.ReadAt(itemsData, itemsOffset); err != nil {
return nil, fmt.Errorf("failed to read APE items: %w", err)
}
items, err := parseAPEItems(itemsData, int(itemCount))
if err != nil {
return nil, fmt.Errorf("failed to parse APE items: %w", err)
}
return &APETag{
Version: version,
Items: items,
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
}, nil
}
func parseAPEItems(data []byte, count int) ([]APETagItem, error) {
items := make([]APETagItem, 0, count)
pos := 0
for i := 0; i < count && pos < len(data); i++ {
if pos+8 > len(data) {
break
}
valueSize := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
itemFlags := binary.LittleEndian.Uint32(data[pos+4 : pos+8])
pos += 8
// Key is null-terminated ASCII (2-255 bytes, case-insensitive)
keyEnd := pos
for keyEnd < len(data) && data[keyEnd] != 0 {
keyEnd++
}
if keyEnd >= len(data) {
break
}
key := string(data[pos:keyEnd])
pos = keyEnd + 1
if pos+valueSize > len(data) {
break
}
value := string(data[pos : pos+valueSize])
pos += valueSize
items = append(items, APETagItem{
Key: key,
Value: value,
Flags: itemFlags,
})
}
return items, nil
}
// WriteAPETags writes APEv2 tags to the end of a file.
// If the file already has APEv2 tags, they are replaced.
// The tag is written with both header and footer.
func WriteAPETags(filePath string, tag *APETag) error {
existingSize, err := findExistingAPETagSize(filePath)
if err != nil {
return fmt.Errorf("failed to check existing APE tag: %w", err)
}
tagData, err := marshalAPETag(tag)
if err != nil {
return fmt.Errorf("failed to marshal APE tag: %w", err)
}
if existingSize > 0 {
fi, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
newSize := fi.Size() - int64(existingSize)
if err := os.Truncate(filePath, newSize); err != nil {
return fmt.Errorf("failed to truncate existing APE tag: %w", err)
}
}
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("failed to open file for writing: %w", err)
}
defer f.Close()
if _, err := f.Write(tagData); err != nil {
return fmt.Errorf("failed to write APE tag: %w", err)
}
return nil
}
// findExistingAPETagSize returns the total size of an existing APE tag
// (header + items + footer) at the end of the file, or 0 if none exists.
func findExistingAPETagSize(filePath string) (int64, error) {
f, err := os.Open(filePath)
if err != nil {
return 0, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return 0, err
}
fileSize := fi.Size()
offsets := []int64{fileSize - apeTagHeaderSize}
if fileSize > apeTagHeaderSize+128 {
offsets = append(offsets, fileSize-apeTagHeaderSize-128)
}
for _, offset := range offsets {
if offset < 0 {
continue
}
footer := make([]byte, apeTagHeaderSize)
if _, err := f.ReadAt(footer, offset); err != nil {
continue
}
if string(footer[0:8]) != apeTagPreamble {
continue
}
flags := binary.LittleEndian.Uint32(footer[20:24])
if (flags & apeTagFlagHeader) != 0 {
continue
}
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
// Check if there's also a header (tagSize only covers items + footer)
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
totalSize := tagSize
if hasHeader {
totalSize += apeTagHeaderSize
}
// Include any trailing data after the footer (e.g. ID3v1 128-byte tag).
// When truncating, we must remove the APE tag AND everything after it.
trailingBytes := fileSize - (offset + apeTagHeaderSize)
totalSize += trailingBytes
return totalSize, nil
}
return 0, nil
}
// marshalAPETag serializes an APETag into bytes (header + items + footer).
func marshalAPETag(tag *APETag) ([]byte, error) {
if tag == nil || len(tag.Items) == 0 {
return nil, fmt.Errorf("empty APE tag")
}
var itemsData []byte
for _, item := range tag.Items {
keyBytes := []byte(item.Key)
valueBytes := []byte(item.Value)
// 4 bytes: value size (LE)
sizeBuf := make([]byte, 4)
binary.LittleEndian.PutUint32(sizeBuf, uint32(len(valueBytes)))
// 4 bytes: item flags (LE)
flagsBuf := make([]byte, 4)
binary.LittleEndian.PutUint32(flagsBuf, item.Flags)
itemsData = append(itemsData, sizeBuf...)
itemsData = append(itemsData, flagsBuf...)
itemsData = append(itemsData, keyBytes...)
itemsData = append(itemsData, 0)
itemsData = append(itemsData, valueBytes...)
}
// tagSize = items data + footer (32 bytes)
tagSize := uint32(len(itemsData) + apeTagHeaderSize)
itemCount := uint32(len(tag.Items))
version := uint32(apeTagVersion2)
if tag.Version != 0 {
version = tag.Version
}
// flags: bit 29 = 1 (is header), bit 31 = 1 (contains header)
headerFlags := uint32(apeTagFlagHeader | (1 << 31))
header := buildAPEHeaderFooter(version, tagSize, itemCount, headerFlags)
// flags: bit 29 = 0 (is footer), bit 31 = 1 (contains header)
footerFlags := uint32(1 << 31)
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
// Final layout: header + items + footer
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
result = append(result, header...)
result = append(result, itemsData...)
result = append(result, footer...)
return result, nil
}
func buildAPEHeaderFooter(version, tagSize, itemCount, flags uint32) []byte {
buf := make([]byte, apeTagHeaderSize)
copy(buf[0:8], apeTagPreamble)
binary.LittleEndian.PutUint32(buf[8:12], version)
binary.LittleEndian.PutUint32(buf[12:16], tagSize)
binary.LittleEndian.PutUint32(buf[16:20], itemCount)
binary.LittleEndian.PutUint32(buf[20:24], flags)
// bytes 24-31 are reserved (zeros)
return buf
}
// APETagToAudioMetadata converts an APETag to our unified AudioMetadata struct.
func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
if tag == nil {
return nil
}
metadata := &AudioMetadata{}
for _, item := range tag.Items {
key := strings.ToUpper(strings.TrimSpace(item.Key))
value := strings.TrimSpace(item.Value)
if value == "" {
continue
}
switch key {
case "TITLE":
metadata.Title = value
case "ARTIST":
metadata.Artist = value
case "ALBUM":
metadata.Album = value
case "ALBUMARTIST", "ALBUM ARTIST":
metadata.AlbumArtist = value
case "GENRE":
metadata.Genre = value
case "YEAR":
metadata.Year = value
case "DATE":
metadata.Date = value
case "TRACK", "TRACKNUMBER":
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "DISC", "DISCNUMBER":
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "ISRC":
metadata.ISRC = value
case "LYRICS", "UNSYNCEDLYRICS":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
case "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
case "COMPOSER":
metadata.Composer = value
case "COMMENT":
metadata.Comment = value
case "REPLAYGAIN_TRACK_GAIN":
metadata.ReplayGainTrackGain = value
case "REPLAYGAIN_TRACK_PEAK":
metadata.ReplayGainTrackPeak = value
case "REPLAYGAIN_ALBUM_GAIN":
metadata.ReplayGainAlbumGain = value
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = value
}
}
return metadata
}
// AudioMetadataToAPEItems converts metadata fields to APE tag items.
func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
if metadata == nil {
return nil
}
var items []APETagItem
addItem := func(key, value string) {
if value != "" {
items = append(items, APETagItem{Key: key, Value: value})
}
}
addItem("Title", metadata.Title)
addItem("Artist", metadata.Artist)
addItem("Album", metadata.Album)
addItem("Album Artist", metadata.AlbumArtist)
addItem("Genre", metadata.Genre)
if metadata.Date != "" {
addItem("Year", metadata.Date)
} else if metadata.Year != "" {
addItem("Year", metadata.Year)
}
if metadata.TrackNumber > 0 {
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
}
if metadata.DiscNumber > 0 {
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
}
addItem("ISRC", metadata.ISRC)
addItem("Lyrics", metadata.Lyrics)
addItem("Label", metadata.Label)
addItem("Copyright", metadata.Copyright)
addItem("Composer", metadata.Composer)
addItem("Comment", metadata.Comment)
addItem("REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
addItem("REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
addItem("REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
addItem("REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak)
return items
}
// apeKeysFromFields builds a set of upper-case APE tag keys corresponding to
// the metadata fields map sent by the editor. This is used during merge to
// ensure that even empty (cleared) fields override old values.
func apeKeysFromFields(fields map[string]string) map[string]struct{} {
mapping := map[string]string{
"title": "TITLE",
"artist": "ARTIST",
"album": "ALBUM",
"album_artist": "ALBUM ARTIST",
"date": "DATE",
"genre": "GENRE",
"track_number": "TRACK",
"disc_number": "DISC",
"isrc": "ISRC",
"lyrics": "LYRICS",
"label": "LABEL",
"copyright": "COPYRIGHT",
"composer": "COMPOSER",
"comment": "COMMENT",
"replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
"replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
"replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
"replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK",
}
result := make(map[string]struct{})
for fk, apeKey := range mapping {
if _, present := fields[fk]; present {
result[strings.ToUpper(apeKey)] = struct{}{}
}
}
// Some fields have reader aliases that must also be cleared when the
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
if _, present := fields["date"]; present {
result["DATE"] = struct{}{}
}
if _, present := fields["disc_number"]; present {
result["DISCNUMBER"] = struct{}{}
}
if _, present := fields["disc_total"]; present {
result["DISCNUMBER"] = struct{}{}
}
if _, present := fields["track_number"]; present {
result["TRACKNUMBER"] = struct{}{}
}
if _, present := fields["track_total"]; present {
result["TRACKNUMBER"] = struct{}{}
}
if _, present := fields["album_artist"]; present {
result["ALBUMARTIST"] = struct{}{}
}
if _, present := fields["label"]; present {
result["PUBLISHER"] = struct{}{}
}
if _, present := fields["lyrics"]; present {
result["UNSYNCEDLYRICS"] = struct{}{}
}
return result
}
// MergeAPEItems overlays newItems on top of existing items.
// For each new item, if a matching key exists (case-insensitive) in existing,
// it is replaced. New keys are appended. Existing items whose keys are NOT
// in newItems are preserved (cover art, ReplayGain, custom tags, etc.).
//
// overrideKeys is an optional set of upper-case keys that should be removed
// from existing even if they do not appear in newItems. This handles field
// deletion: the caller sends an empty value which is not serialized into
// newItems, but the old value must still be dropped.
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
// Build a set of keys being updated (upper-case for case-insensitive match)
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
for k := range overrideKeys {
combined[strings.ToUpper(k)] = struct{}{}
}
for _, item := range newItems {
combined[strings.ToUpper(item.Key)] = struct{}{}
}
var merged []APETagItem
for _, item := range existing {
if _, overwritten := combined[strings.ToUpper(item.Key)]; !overwritten {
merged = append(merged, item)
}
}
merged = append(merged, newItems...)
return merged
}
// ReadAPETagsFromReader reads APEv2 tags from an io.ReaderAt + size.
// This is useful for reading APE tags from files opened via SAF or other abstractions.
func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
if fileSize < apeTagHeaderSize {
return nil, fmt.Errorf("file too small for APE tag")
}
// Try footer at end of file
footer := make([]byte, apeTagHeaderSize)
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
return nil, fmt.Errorf("failed to read APE footer: %w", err)
}
if string(footer[0:8]) == apeTagPreamble {
tag, err := parseAPETagFromFooter(r, fileSize, fileSize-apeTagHeaderSize, footer)
if err == nil {
return tag, nil
}
}
// Retry: skip ID3v1 tag (128 bytes)
if fileSize > apeTagHeaderSize+128 {
offset := fileSize - apeTagHeaderSize - 128
if _, err := r.ReadAt(footer, offset); err == nil {
if string(footer[0:8]) == apeTagPreamble {
tag, err := parseAPETagFromFooter(r, fileSize, offset, footer)
if err == nil {
return tag, nil
}
}
}
}
return nil, fmt.Errorf("no APEv2 tag found")
}
func parseAPETagFromFooter(r io.ReaderAt, fileSize, footerOffset int64, footer []byte) (*APETag, error) {
version := binary.LittleEndian.Uint32(footer[8:12])
tagSize := binary.LittleEndian.Uint32(footer[12:16])
itemCount := binary.LittleEndian.Uint32(footer[16:20])
flags := binary.LittleEndian.Uint32(footer[20:24])
if version != apeTagVersion2 && version != 1000 {
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
}
if tagSize < apeTagHeaderSize {
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
}
if itemCount > 1000 {
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
}
if (flags & apeTagFlagHeader) != 0 {
return nil, fmt.Errorf("expected footer, found header")
}
itemsSize := int64(tagSize) - apeTagHeaderSize
itemsOffset := footerOffset - itemsSize
if itemsOffset < 0 {
return nil, fmt.Errorf("APE items extend before file start")
}
itemsData := make([]byte, itemsSize)
if _, err := r.ReadAt(itemsData, itemsOffset); err != nil {
return nil, fmt.Errorf("failed to read APE items: %w", err)
}
items, err := parseAPEItems(itemsData, int(itemCount))
if err != nil {
return nil, fmt.Errorf("failed to parse APE items: %w", err)
}
return &APETag{
Version: version,
Items: items,
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
}, nil
}
+84 -60
View File
@@ -21,13 +21,20 @@ type AudioMetadata struct {
Year string Year string
Date string Date string
TrackNumber int TrackNumber int
TotalTracks int
DiscNumber int DiscNumber int
TotalDiscs int
ISRC string ISRC string
Lyrics string Lyrics string
Label string Label string
Copyright string Copyright string
Composer string Composer string
Comment string Comment string
// ReplayGain fields (text values, e.g. "-6.50 dB", "0.988831")
ReplayGainTrackGain string
ReplayGainTrackPeak string
ReplayGainAlbumGain string
ReplayGainAlbumPeak string
} }
type MP3Quality struct { type MP3Quality struct {
@@ -168,9 +175,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
case "TCO": case "TCO":
metadata.Genre = cleanGenre(value) metadata.Genre = cleanGenre(value)
case "TRK": case "TRK":
metadata.TrackNumber = parseTrackNumber(value) metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "TPA": case "TPA":
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "TCM": case "TCM":
metadata.Composer = value metadata.Composer = value
case "TPB": case "TPB":
@@ -287,9 +294,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
case "TCON": case "TCON":
metadata.Genre = cleanGenre(value) metadata.Genre = cleanGenre(value)
case "TRCK": case "TRCK":
metadata.TrackNumber = parseTrackNumber(value) metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "TPOS": case "TPOS":
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "TSRC": case "TSRC":
metadata.ISRC = value metadata.ISRC = value
case "TCOM": case "TCOM":
@@ -311,6 +318,17 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" { if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
metadata.Lyrics = userValue metadata.Lyrics = userValue
} }
upperDesc := strings.ToUpper(desc)
switch upperDesc {
case "REPLAYGAIN_TRACK_GAIN":
metadata.ReplayGainTrackGain = userValue
case "REPLAYGAIN_TRACK_PEAK":
metadata.ReplayGainTrackPeak = userValue
case "REPLAYGAIN_ALBUM_GAIN":
metadata.ReplayGainAlbumGain = userValue
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = userValue
}
} }
pos += 10 + frameSize pos += 10 + frameSize
@@ -338,7 +356,6 @@ func readID3v1(file *os.File) (*AudioMetadata, error) {
Year: strings.TrimRight(string(tag[93:97]), " \x00"), Year: strings.TrimRight(string(tag[93:97]), " \x00"),
} }
// ID3v1.1 track number (if byte 125 is 0 and byte 126 is not)
if tag[125] == 0 && tag[126] != 0 { if tag[125] == 0 && tag[126] != 0 {
metadata.TrackNumber = int(tag[126]) metadata.TrackNumber = int(tag[126])
} }
@@ -373,27 +390,23 @@ func extractTextFrame(data []byte) string {
} }
} }
// extractCommentFrame parses an ID3v2 COMM frame.
// Format: encoding(1) + language(3) + description(null-terminated) + text
func extractCommentFrame(data []byte) string { func extractCommentFrame(data []byte) string {
if len(data) < 5 { if len(data) < 5 {
return "" return ""
} }
encoding := data[0] encoding := data[0]
// skip 3-byte language code
rest := data[4:] rest := data[4:]
// find null terminator separating description from text
var text []byte var text []byte
switch encoding { switch encoding {
case 1, 2: // UTF-16 variants use double-null terminator case 1, 2:
for i := 0; i+1 < len(rest); i += 2 { for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 { if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:] text = rest[i+2:]
break break
} }
} }
default: // ISO-8859-1 or UTF-8 default:
idx := bytes.IndexByte(rest, 0) idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) { if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:] text = rest[idx+1:]
@@ -406,33 +419,30 @@ func extractCommentFrame(data []byte) string {
return "" return ""
} }
// re-prepend encoding byte so extractTextFrame can decode properly
framed := make([]byte, 1+len(text)) framed := make([]byte, 1+len(text))
framed[0] = encoding framed[0] = encoding
copy(framed[1:], text) copy(framed[1:], text)
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 { func extractLyricsFrame(data []byte) string {
if len(data) < 5 { if len(data) < 5 {
return "" return ""
} }
encoding := data[0] encoding := data[0]
rest := data[4:] // skip 3-byte language code rest := data[4:]
var text []byte var text []byte
switch encoding { switch encoding {
case 1, 2: // UTF-16 variants use double-null terminator case 1, 2:
for i := 0; i+1 < len(rest); i += 2 { for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 { if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:] text = rest[i+2:]
break break
} }
} }
default: // ISO-8859-1 or UTF-8 default:
idx := bytes.IndexByte(rest, 0) idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) { if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:] text = rest[idx+1:]
@@ -451,8 +461,6 @@ func extractLyricsFrame(data []byte) string {
return extractTextFrame(framed) return extractTextFrame(framed)
} }
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
// encoding(1) + description + separator + value.
func extractUserTextFrame(data []byte) (string, string) { func extractUserTextFrame(data []byte) (string, string) {
if len(data) < 2 { if len(data) < 2 {
return "", "" return "", ""
@@ -463,7 +471,7 @@ func extractUserTextFrame(data []byte) (string, string) {
var descRaw, valueRaw []byte var descRaw, valueRaw []byte
switch encoding { switch encoding {
case 1, 2: // UTF-16 variants case 1, 2:
for i := 0; i+1 < len(payload); i += 2 { for i := 0; i+1 < len(payload); i += 2 {
if payload[i] == 0 && payload[i+1] == 0 { if payload[i] == 0 && payload[i+1] == 0 {
descRaw = payload[:i] descRaw = payload[:i]
@@ -471,7 +479,7 @@ func extractUserTextFrame(data []byte) (string, string) {
break break
} }
} }
default: // ISO-8859-1 or UTF-8 default:
idx := bytes.IndexByte(payload, 0) idx := bytes.IndexByte(payload, 0)
if idx >= 0 { if idx >= 0 {
descRaw = payload[:idx] descRaw = payload[:idx]
@@ -574,14 +582,28 @@ func cleanGenre(genre string) string {
} }
func parseTrackNumber(s string) int { func parseTrackNumber(s string) int {
s = strings.TrimSpace(s) num, _ := parseIndexPair(s)
if idx := strings.Index(s, "/"); idx > 0 {
s = s[:idx]
}
num, _ := strconv.Atoi(s)
return num return num
} }
func parseIndexPair(s string) (int, int) {
s = strings.TrimSpace(s)
if s == "" {
return 0, 0
}
first := s
second := ""
if idx := strings.Index(s, "/"); idx > 0 {
first = s[:idx]
second = s[idx+1:]
}
num, _ := strconv.Atoi(strings.TrimSpace(first))
total, _ := strconv.Atoi(strings.TrimSpace(second))
return num, total
}
func removeUnsync(data []byte) []byte { func removeUnsync(data []byte) []byte {
if len(data) == 0 { if len(data) == 0 {
return data return data
@@ -665,7 +687,6 @@ 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)
var frameStart int64 = -1 var frameStart int64 = -1
for i := 0; i < 10000; i++ { for i := 0; i < 10000; i++ {
@@ -692,8 +713,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
sampleRateIdx := (frameHeader[2] >> 2) & 0x03 sampleRateIdx := (frameHeader[2] >> 2) & 0x03
channelMode := (frameHeader[3] >> 6) & 0x03 channelMode := (frameHeader[3] >> 6) & 0x03
// Sample rate tables: [version][index]
// version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1
sampleRates := [][]int{ sampleRates := [][]int{
{11025, 12000, 8000}, {11025, 12000, 8000},
{0, 0, 0}, {0, 0, 0},
@@ -704,15 +723,12 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
quality.SampleRate = sampleRates[version][sampleRateIdx] quality.SampleRate = sampleRates[version][sampleRateIdx]
} }
// Bitrate tables for all MPEG versions and layers
// MPEG1 Layer III
if version == 3 && layer == 1 { if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0} bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 { if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000 quality.Bitrate = bitrates[bitrateIdx] * 1000
} }
} }
// MPEG2/2.5 Layer III
if (version == 0 || version == 2) && layer == 1 { 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} bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
if bitrateIdx < 16 { if bitrateIdx < 16 {
@@ -720,14 +736,11 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
} }
} }
// Determine samples per frame for duration calculation
samplesPerFrame := 1152 // MPEG1 Layer III samplesPerFrame := 1152 // MPEG1 Layer III
if version == 0 || version == 2 { if version == 0 || version == 2 {
samplesPerFrame = 576 // MPEG2/2.5 Layer III 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 var xingOffset int
if version == 3 { // MPEG1 if version == 3 { // MPEG1
if channelMode == 3 { // Mono if channelMode == 3 { // Mono
@@ -743,7 +756,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
} }
} }
// Read enough of the first frame to find Xing/VBRI header
xingBuf := make([]byte, 200) xingBuf := make([]byte, 200)
file.Seek(frameStart+4, io.SeekStart) file.Seek(frameStart+4, io.SeekStart)
n, _ := io.ReadFull(file, xingBuf) n, _ := io.ReadFull(file, xingBuf)
@@ -753,7 +765,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
vbrBytes := int64(0) vbrBytes := int64(0)
isVBR := false isVBR := false
// Check for Xing/Info header
if xingOffset+8 <= n { if xingOffset+8 <= n {
tag := string(xingBuf[xingOffset : xingOffset+4]) tag := string(xingBuf[xingOffset : xingOffset+4])
if tag == "Xing" || tag == "Info" { if tag == "Xing" || tag == "Info" {
@@ -772,7 +783,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
} }
} }
// Check for VBRI header (always at offset 32 from frame start + 4)
if !isVBR && 36+26 <= n { if !isVBR && 36+26 <= n {
if string(xingBuf[32:36]) == "VBRI" { if string(xingBuf[32:36]) == "VBRI" {
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10])) vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
@@ -784,11 +794,9 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
} }
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 { if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
// Accurate duration from total frames
totalSamples := int64(vbrFrames) * int64(samplesPerFrame) totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
quality.Duration = int(totalSamples / int64(quality.SampleRate)) quality.Duration = int(totalSamples / int64(quality.SampleRate))
// Accurate average bitrate
if vbrBytes > 0 && quality.Duration > 0 { if vbrBytes > 0 && quality.Duration > 0 {
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration)) quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
} else if quality.Duration > 0 { } else if quality.Duration > 0 {
@@ -796,7 +804,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration)) quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
} }
} else if quality.Bitrate > 0 { } else if quality.Bitrate > 0 {
// CBR fallback: estimate duration from file size and frame bitrate
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
if audioSize > 0 { if audioSize > 0 {
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate)) quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
@@ -980,8 +987,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
} }
reader := bytes.NewReader(data) reader := bytes.NewReader(data)
artistValues := make([]string, 0, 1)
albumArtistValues := make([]string, 0, 1)
// Read vendor string length
var vendorLen uint32 var vendorLen uint32
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil { if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
return return
@@ -1010,8 +1018,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
if commentLen > remaining { 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 { if commentLen > 512*1024 {
reader.Seek(int64(commentLen), io.SeekCurrent) reader.Seek(int64(commentLen), io.SeekCurrent)
continue continue
@@ -1034,9 +1040,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
case "TITLE": case "TITLE":
metadata.Title = value metadata.Title = value
case "ARTIST": case "ARTIST":
metadata.Artist = value artistValues = append(artistValues, value)
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST": case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
metadata.AlbumArtist = value albumArtistValues = append(albumArtistValues, value)
case "ALBUM": case "ALBUM":
metadata.Album = value metadata.Album = value
case "DATE", "YEAR": case "DATE", "YEAR":
@@ -1047,9 +1053,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
case "GENRE": case "GENRE":
metadata.Genre = value metadata.Genre = value
case "TRACKNUMBER", "TRACK": case "TRACKNUMBER", "TRACK":
metadata.TrackNumber = parseTrackNumber(value) metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "DISCNUMBER", "DISC": case "DISCNUMBER", "DISC":
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "ISRC": case "ISRC":
metadata.ISRC = value metadata.ISRC = value
case "COMPOSER": case "COMPOSER":
@@ -1064,8 +1070,23 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
metadata.Label = value metadata.Label = value
case "COPYRIGHT": case "COPYRIGHT":
metadata.Copyright = value metadata.Copyright = value
case "REPLAYGAIN_TRACK_GAIN":
metadata.ReplayGainTrackGain = value
case "REPLAYGAIN_TRACK_PEAK":
metadata.ReplayGainTrackPeak = value
case "REPLAYGAIN_ALBUM_GAIN":
metadata.ReplayGainAlbumGain = value
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = value
} }
} }
if len(artistValues) > 0 {
metadata.Artist = joinVorbisCommentValues(artistValues)
}
if len(albumArtistValues) > 0 {
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
}
} }
func GetOggQuality(filePath string) (*OggQuality, error) { func GetOggQuality(filePath string) (*OggQuality, error) {
@@ -1114,7 +1135,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
} }
} }
// 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 {
return quality, nil return quality, nil
@@ -1124,7 +1144,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
granule := readLastOggGranulePosition(file, fileSize) granule := readLastOggGranulePosition(file, fileSize)
if granule > 0 { if granule > 0 {
if isOpus { if isOpus {
// Opus always uses 48kHz granule position internally
totalSamples := granule - int64(preSkip) totalSamples := granule - int64(preSkip)
if totalSamples > 0 { if totalSamples > 0 {
durationSec := float64(totalSamples) / 48000.0 durationSec := float64(totalSamples) / 48000.0
@@ -1142,11 +1161,9 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
} }
} }
// Fallback bitrate estimate if duration exists but bitrate couldn't be derived.
if quality.Bitrate <= 0 && quality.Duration > 0 { if quality.Bitrate <= 0 && quality.Duration > 0 {
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration)) quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
} }
// Guard against obviously invalid values from corrupted/unreliable granule reads.
if quality.Duration > 24*60*60 { if quality.Duration > 24*60*60 {
quality.Duration = 0 quality.Duration = 0
quality.Bitrate = 0 quality.Bitrate = 0
@@ -1158,10 +1175,7 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
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 { func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
// Read the last chunk of the file to find the last OggS sync
searchSize := int64(65536) searchSize := int64(65536)
if searchSize > fileSize { if searchSize > fileSize {
searchSize = fileSize searchSize = fileSize
@@ -1185,7 +1199,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
if i+27 > n { if i+27 > n {
continue continue
} }
// Validate minimal header fields to avoid false positives inside payload bytes.
version := buf[i+4] version := buf[i+4]
headerType := buf[i+5] headerType := buf[i+5]
if version != 0 || headerType > 0x07 { if version != 0 || headerType > 0x07 {
@@ -1203,7 +1216,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
if i+headerLen+payloadLen > n { if i+headerLen+payloadLen > n {
continue continue
} }
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64).
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14])) return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
} }
return 0 return 0
@@ -1263,7 +1275,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
return nil, "", err return nil, "", err
} }
// Parse frames looking for APIC (Attached Picture)
pos := 0 pos := 0
var frameIDLen, headerLen int var frameIDLen, headerLen int
if majorVersion == 2 { if majorVersion == 2 {
@@ -1294,7 +1305,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
break break
} }
// Check for APIC (ID3v2.3/2.4) or PIC (ID3v2.2)
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") { if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
frameData := tagData[pos+headerLen : pos+headerLen+frameSize] frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
imageData, mimeType := parseAPICFrame(frameData, majorVersion) imageData, mimeType := parseAPICFrame(frameData, majorVersion)
@@ -1620,14 +1630,28 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
} }
func SaveCoverToCache(filePath, cacheDir string) (string, error) { func SaveCoverToCache(filePath, cacheDir string) (string, error) {
return SaveCoverToCacheWithHint(filePath, "", cacheDir) return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
} }
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) { func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
}
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
explicitKey = strings.TrimSpace(explicitKey)
if explicitKey != "" {
return explicitKey
}
cacheKey := filePath cacheKey := filePath
if stat, err := os.Stat(filePath); err == nil { if stat, err := os.Stat(filePath); err == nil {
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano()) cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
} }
return cacheKey
}
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
hash := hashString(cacheKey) hash := hashString(cacheKey)
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash)) jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
+34
View File
@@ -0,0 +1,34 @@
package gobackend
import (
"os"
"strings"
"testing"
)
func TestResolveLibraryCoverCacheKeyUsesExplicitKey(t *testing.T) {
t.Parallel()
const explicitKey = "content://media/external/audio/media/42|123456"
got := resolveLibraryCoverCacheKey("/tmp/saf_random.flac", explicitKey)
if got != explicitKey {
t.Fatalf("expected explicit cache key %q, got %q", explicitKey, got)
}
}
func TestResolveLibraryCoverCacheKeyUsesFilePathAndStatWhenNoExplicitKey(t *testing.T) {
t.Parallel()
tempFile, err := os.CreateTemp("", "cover-cache-*.flac")
if err != nil {
t.Fatalf("CreateTemp failed: %v", err)
}
tempPath := tempFile.Name()
tempFile.Close()
defer os.Remove(tempPath)
got := resolveLibraryCoverCacheKey(tempPath, "")
if !strings.HasPrefix(got, tempPath+"|") {
t.Fatalf("expected stat-based cache key to start with %q, got %q", tempPath+"|", got)
}
}
+41 -56
View File
@@ -11,7 +11,6 @@ import (
"strings" "strings"
) )
// CueSheet represents a parsed .cue file
type CueSheet struct { type CueSheet struct {
Performer string `json:"performer"` Performer string `json:"performer"`
Title string `json:"title"` Title string `json:"title"`
@@ -24,18 +23,16 @@ type CueSheet struct {
Tracks []CueTrack `json:"tracks"` Tracks []CueTrack `json:"tracks"`
} }
// CueTrack represents a single track in a cue sheet
type CueTrack struct { type CueTrack struct {
Number int `json:"number"` Number int `json:"number"`
Title string `json:"title"` Title string `json:"title"`
Performer string `json:"performer"` Performer string `json:"performer"`
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"` Composer string `json:"composer,omitempty"`
StartTime float64 `json:"start_time"` // INDEX 01 in seconds StartTime float64 `json:"start_time"` // INDEX 01 in seconds
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present) PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
} }
// CueSplitInfo represents the information needed to split a CUE+audio file
type CueSplitInfo struct { type CueSplitInfo struct {
CuePath string `json:"cue_path"` CuePath string `json:"cue_path"`
AudioPath string `json:"audio_path"` AudioPath string `json:"audio_path"`
@@ -46,7 +43,6 @@ type CueSplitInfo struct {
Tracks []CueSplitTrack `json:"tracks"` Tracks []CueSplitTrack `json:"tracks"`
} }
// CueSplitTrack has the FFmpeg split parameters for a single track
type CueSplitTrack struct { type CueSplitTrack struct {
Number int `json:"number"` Number int `json:"number"`
Title string `json:"title"` Title string `json:"title"`
@@ -62,7 +58,6 @@ var (
reQuoted = regexp.MustCompile(`"([^"]*)"`) reQuoted = regexp.MustCompile(`"([^"]*)"`)
) )
// ParseCueFile parses a .cue file and returns a CueSheet
func ParseCueFile(cuePath string) (*CueSheet, error) { func ParseCueFile(cuePath string) (*CueSheet, error) {
f, err := os.Open(cuePath) f, err := os.Open(cuePath)
if err != nil { if err != nil {
@@ -202,7 +197,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
return sheet, nil return sheet, nil
} }
// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds
func parseCueTimestamp(ts string) float64 { func parseCueTimestamp(ts string) float64 {
parts := strings.Split(ts, ":") parts := strings.Split(ts, ":")
if len(parts) != 3 { if len(parts) != 3 {
@@ -216,7 +210,6 @@ func parseCueTimestamp(ts string) float64 {
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0 return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
} }
// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg
func formatCueTimestamp(seconds float64) string { func formatCueTimestamp(seconds float64) string {
if seconds < 0 { if seconds < 0 {
return "0" return "0"
@@ -227,7 +220,6 @@ func formatCueTimestamp(seconds float64) string {
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs) return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
} }
// unquoteCue removes surrounding quotes from a CUE value
func unquoteCue(s string) string { func unquoteCue(s string) string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 { if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
@@ -236,14 +228,12 @@ func unquoteCue(s string) string {
return s return s
} }
// parseCueFileLine parses the FILE command's filename and type
func parseCueFileLine(rest string) (string, string) { func parseCueFileLine(rest string) (string, string) {
rest = strings.TrimSpace(rest) rest = strings.TrimSpace(rest)
var filename, ftype string var filename, ftype string
if strings.HasPrefix(rest, "\"") { if strings.HasPrefix(rest, "\"") {
// Quoted filename
endQuote := strings.Index(rest[1:], "\"") endQuote := strings.Index(rest[1:], "\"")
if endQuote >= 0 { if endQuote >= 0 {
filename = rest[1 : endQuote+1] filename = rest[1 : endQuote+1]
@@ -253,7 +243,6 @@ func parseCueFileLine(rest string) (string, string) {
filename = rest filename = rest
} }
} else { } else {
// Unquoted filename - last word is the type
parts := strings.Fields(rest) parts := strings.Fields(rest)
if len(parts) >= 2 { if len(parts) >= 2 {
ftype = parts[len(parts)-1] ftype = parts[len(parts)-1]
@@ -266,18 +255,14 @@ func parseCueFileLine(rest string) (string, string) {
return filename, strings.TrimSpace(ftype) return filename, strings.TrimSpace(ftype)
} }
// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet.
// It checks relative to the cue file's directory.
func ResolveCueAudioPath(cuePath string, cueFileName string) string { func ResolveCueAudioPath(cuePath string, cueFileName string) string {
cueDir := filepath.Dir(cuePath) cueDir := filepath.Dir(cuePath)
// 1. Try the exact filename from the .cue
candidate := filepath.Join(cueDir, cueFileName) candidate := filepath.Join(cueDir, cueFileName)
if _, err := os.Stat(candidate); err == nil { if _, err := os.Stat(candidate); err == nil {
return candidate return candidate
} }
// 2. Try common case variations
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName)) baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"} commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
for _, ext := range commonExts { for _, ext := range commonExts {
@@ -285,14 +270,12 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
if _, err := os.Stat(candidate); err == nil { if _, err := os.Stat(candidate); err == nil {
return candidate return candidate
} }
// Try uppercase ext
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext)) candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
if _, err := os.Stat(candidate); err == nil { if _, err := os.Stat(candidate); err == nil {
return candidate return candidate
} }
} }
// 3. Try to find any audio file with the same base name as the .cue file
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath)) cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
for _, ext := range commonExts { for _, ext := range commonExts {
candidate = filepath.Join(cueDir, cueBase+ext) candidate = filepath.Join(cueDir, cueBase+ext)
@@ -301,7 +284,6 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
} }
} }
// 4. If there's only one audio file in the directory, use that
entries, err := os.ReadDir(cueDir) entries, err := os.ReadDir(cueDir)
if err == nil { if err == nil {
audioExts := map[string]bool{ audioExts := map[string]bool{
@@ -326,13 +308,9 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
return "" return ""
} }
// BuildCueSplitInfo creates the split information from a parsed CUE sheet.
// This is returned to the Dart side so FFmpeg can perform the splitting.
// audioDir, if non-empty, overrides the directory for audio file resolution.
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) { func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
resolveDir := cuePath resolveDir := cuePath
if audioDir != "" { if audioDir != "" {
// Create a virtual path in audioDir so ResolveCueAudioPath looks there
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath)) resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
} }
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName) audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
@@ -360,11 +338,9 @@ func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSp
composer = sheet.Composer composer = sheet.Composer
} }
// End time is the start of the next track, or -1 for the last track
endSec := float64(-1) endSec := float64(-1)
if i+1 < len(sheet.Tracks) { if i+1 < len(sheet.Tracks) {
nextTrack := sheet.Tracks[i+1] nextTrack := sheet.Tracks[i+1]
// Use pre-gap of next track if available, otherwise its start time
if nextTrack.PreGap >= 0 { if nextTrack.PreGap >= 0 {
endSec = nextTrack.PreGap endSec = nextTrack.PreGap
} else { } else {
@@ -386,11 +362,6 @@ func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSp
return info, nil return info, nil
} }
// ParseCueFileJSON parses a .cue file and returns JSON with split info.
// This is the main entry point called from Dart via the platform bridge.
// audioDir, if non-empty, overrides the directory used for resolving the
// referenced audio file (useful when the .cue was copied to a temp dir
// but the audio still lives in the original location, e.g. SAF).
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) { func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
sheet, err := ParseCueFile(cuePath) sheet, err := ParseCueFile(cuePath)
if err != nil { if err != nil {
@@ -410,9 +381,6 @@ func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult
// entries, one per track. This is used by the library scanner to populate the
// library with individual track entries from a single CUE+FLAC album.
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) { func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
sheet, err := ParseCueFile(cuePath) sheet, err := ParseCueFile(cuePath)
if err != nil { if err != nil {
@@ -422,17 +390,21 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
if err != nil { if err != nil {
return nil, err return nil, err
} }
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime) return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
} }
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
// for SAF (Storage Access Framework) scenarios:
// - audioDir: if non-empty, overrides the directory used to find the audio file
// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for
// virtual file paths (e.g. a content:// URI). IDs are also based on this.
// - fileModTime: if > 0, used as the FileModTime for all results instead of
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
return ScanCueFileForLibraryExtWithCoverCacheKey(
cuePath,
audioDir,
virtualPathPrefix,
fileModTime,
"",
scanTime,
)
}
func ScanCueFileForLibraryExtWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
sheet, err := ParseCueFile(cuePath) sheet, err := ParseCueFile(cuePath)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -441,7 +413,15 @@ func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileM
if err != nil { if err != nil {
return nil, err return nil, err
} }
return scanCueSheetForLibrary(cuePath, sheet, audioPath, virtualPathPrefix, fileModTime, scanTime) return scanCueSheetForLibrary(
cuePath,
sheet,
audioPath,
virtualPathPrefix,
fileModTime,
coverCacheKey,
scanTime,
)
} }
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) { func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
@@ -459,12 +439,11 @@ func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir str
return audioPath, nil return audioPath, nil
} }
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
if sheet == nil { if sheet == nil {
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath) return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
} }
// Try to get quality info from the audio file
var bitDepth, sampleRate int var bitDepth, sampleRate int
var totalDurationSec float64 var totalDurationSec float64
audioExt := strings.ToLower(filepath.Ext(audioPath)) audioExt := strings.ToLower(filepath.Ext(audioPath))
@@ -486,25 +465,27 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
} }
} }
// Extract cover from audio file for all tracks
var coverPath string var coverPath string
libraryCoverCacheMu.RLock() libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock() libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" { if coverCacheDir != "" {
cp, err := SaveCoverToCache(audioPath, coverCacheDir) cp, err := SaveCoverToCacheWithHintAndKey(
audioPath,
"",
coverCacheDir,
coverCacheKey,
)
if err == nil && cp != "" { if err == nil && cp != "" {
coverPath = cp coverPath = cp
} }
} }
// Determine the base path for virtual paths and IDs
pathBase := cuePath pathBase := cuePath
if virtualPathPrefix != "" { if virtualPathPrefix != "" {
pathBase = virtualPathPrefix pathBase = virtualPathPrefix
} }
// Determine fileModTime
modTime := fileModTime modTime := fileModTime
if modTime <= 0 { if modTime <= 0 {
if info, err := os.Stat(cuePath); err == nil { if info, err := os.Stat(cuePath); err == nil {
@@ -532,7 +513,11 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
album = "Unknown Album" album = "Unknown Album"
} }
// Calculate duration for this track composer := track.Composer
if composer == "" {
composer = sheet.Composer
}
var duration int var duration int
if i+1 < len(sheet.Tracks) { if i+1 < len(sheet.Tracks) {
nextStart := sheet.Tracks[i+1].StartTime nextStart := sheet.Tracks[i+1].StartTime
@@ -546,9 +531,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number)) id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
// Use a virtual file path that includes the track number to ensure
// uniqueness in the database (file_path has a UNIQUE constraint).
// Format: /path/to/album.cue#track01 or content://...album.cue#track01
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number) virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
result := LibraryScanResult{ result := LibraryScanResult{
@@ -562,12 +544,15 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
ScannedAt: scanTime, ScannedAt: scanTime,
ISRC: track.ISRC, ISRC: track.ISRC,
TrackNumber: track.Number, TrackNumber: track.Number,
TotalTracks: len(sheet.Tracks),
DiscNumber: 1, DiscNumber: 1,
TotalDiscs: 1,
Duration: duration, Duration: duration,
ReleaseDate: sheet.Date, ReleaseDate: sheet.Date,
BitDepth: bitDepth, BitDepth: bitDepth,
SampleRate: sampleRate, SampleRate: sampleRate,
Genre: sheet.Genre, Genre: sheet.Genre,
Composer: composer,
Format: "cue+" + strings.TrimPrefix(audioExt, "."), Format: "cue+" + strings.TrimPrefix(audioExt, "."),
} }
+80 -5
View File
@@ -196,15 +196,22 @@ type deezerAlbumSimple struct {
RecordType string `json:"record_type"` RecordType string `json:"record_type"`
} }
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { // deezerTrackArtistDisplay returns the display artist string for a track,
artistName := track.Artist.Name // preferring the Contributors list (comma-joined) when available, falling
// back to the primary Artist.Name.
func deezerTrackArtistDisplay(track deezerTrack) string {
if len(track.Contributors) > 0 { if len(track.Contributors) > 0 {
names := make([]string, len(track.Contributors)) names := make([]string, len(track.Contributors))
for i, a := range track.Contributors { for i, a := range track.Contributors {
names[i] = a.Name names[i] = a.Name
} }
artistName = strings.Join(names, ", ") return strings.Join(names, ", ")
} }
return track.Artist.Name
}
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
artistName := deezerTrackArtistDisplay(track)
albumImage := track.Album.CoverXL albumImage := track.Album.CoverXL
if albumImage == "" { if albumImage == "" {
@@ -623,6 +630,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
} }
isrcMap := c.fetchISRCsParallel(ctx, allTracks) isrcMap := c.fetchISRCsParallel(ctx, allTracks)
totalDiscs := 0
for _, track := range allTracks {
if track.DiskNumber > totalDiscs {
totalDiscs = track.DiskNumber
}
}
tracks := make([]AlbumTrackMetadata, 0, len(allTracks)) tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
albumType := album.RecordType albumType := album.RecordType
@@ -641,7 +654,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
tracks = append(tracks, AlbumTrackMetadata{ tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID), SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name, Artists: deezerTrackArtistDisplay(track),
Name: track.Title, Name: track.Title,
AlbumName: album.Title, AlbumName: album.Title,
AlbumArtist: artistName, AlbumArtist: artistName,
@@ -651,6 +664,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
TrackNumber: trackNum, TrackNumber: trackNum,
TotalTracks: album.NbTracks, TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber, DiscNumber: track.DiskNumber,
TotalDiscs: totalDiscs,
ExternalURL: track.Link, ExternalURL: track.Link,
ISRC: isrc, ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", album.ID), AlbumID: fmt.Sprintf("deezer:%d", album.ID),
@@ -741,6 +755,10 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
Artists: artist.Name, Artists: artist.Name,
}) })
} }
// The Deezer /artist/{id}/albums endpoint does not return nb_tracks.
// Fetch track counts in parallel from individual /album/{id} endpoints.
c.fetchAlbumTrackCounts(ctx, albums)
} }
result := &ArtistResponsePayload{ result := &ArtistResponsePayload{
@@ -760,6 +778,63 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
return result, nil return result, nil
} }
// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using
// individual /album/{id} calls, since the /artist/{id}/albums endpoint does
// not include this field. Albums whose track count is already known (non-zero)
// are skipped.
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
// Find albums that need track counts
type indexedID struct {
idx int
albumID string
}
var toFetch []indexedID
for i, a := range albums {
if a.TotalTracks == 0 {
rawID := strings.TrimPrefix(a.ID, "deezer:")
if rawID != "" {
toFetch = append(toFetch, indexedID{idx: i, albumID: rawID})
}
}
}
if len(toFetch) == 0 {
return
}
const maxParallel = 10
sem := make(chan struct{}, maxParallel)
var mu sync.Mutex
var wg sync.WaitGroup
for _, item := range toFetch {
wg.Add(1)
go func(it indexedID) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID)
var resp struct {
NbTracks int `json:"nb_tracks"`
}
if err := c.getJSON(ctx, albumURL, &resp); err != nil {
return
}
mu.Lock()
albums[it.idx].TotalTracks = resp.NbTracks
mu.Unlock()
}(item)
}
wg.Wait()
}
func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) { func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:")) normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:"))
if normalizedArtistID == "" { if normalizedArtistID == "" {
@@ -892,7 +967,7 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
tracks = append(tracks, AlbumTrackMetadata{ tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID), SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name, Artists: deezerTrackArtistDisplay(track),
Name: track.Title, Name: track.Title,
AlbumName: track.Album.Title, AlbumName: track.Album.Title,
AlbumArtist: track.Artist.Name, AlbumArtist: track.Artist.Name,
+44 -203
View File
@@ -14,14 +14,7 @@ import (
"strings" "strings"
) )
const deezerYoinkifyURL = "https://yoinkify.lol/api/download" const deezerMusicDLURL = "https://api.zarz.moe/v1/dzr"
const deezerMusicDLURL = "https://www.musicdl.me/api/download"
type YoinkifyRequest struct {
URL string `json:"url"`
Format string `json:"format"`
GenreSource string `json:"genreSource"`
}
type DeezerDownloadResult struct { type DeezerDownloadResult struct {
FilePath string FilePath string
@@ -37,41 +30,6 @@ type DeezerDownloadResult struct {
LyricsLRC string LyricsLRC string
} }
func resolveSpotifyURLForYoinkify(req DownloadRequest) (string, error) {
rawSpotify := strings.TrimSpace(req.SpotifyID)
if rawSpotify != "" {
if isLikelySpotifyTrackID(rawSpotify) {
return fmt.Sprintf("https://open.spotify.com/track/%s", rawSpotify), nil
}
if parsed, err := parseSpotifyURI(rawSpotify); err == nil && parsed.Type == "track" && parsed.ID != "" {
return fmt.Sprintf("https://open.spotify.com/track/%s", parsed.ID), nil
}
}
deezerID := strings.TrimSpace(req.DeezerID)
if deezerID == "" {
if prefixed, found := strings.CutPrefix(rawSpotify, "deezer:"); found {
deezerID = strings.TrimSpace(prefixed)
}
}
if deezerID != "" {
songlink := NewSongLinkClient()
spotifyID, err := songlink.GetSpotifyIDFromDeezer(deezerID)
if err != nil {
return "", fmt.Errorf("failed to map deezer:%s to Spotify ID: %w", deezerID, err)
}
spotifyID = strings.TrimSpace(spotifyID)
if spotifyID == "" {
return "", fmt.Errorf("SongLink returned empty Spotify ID for deezer:%s", deezerID)
}
return fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID), nil
}
return "", fmt.Errorf("missing Spotify track ID for Deezer Yoinkify")
}
func isLikelySpotifyTrackID(value string) bool { func isLikelySpotifyTrackID(value string) bool {
if len(value) != 22 { if len(value) != 22 {
return false return false
@@ -88,113 +46,6 @@ func isLikelySpotifyTrackID(value string) bool {
return true return true
} }
func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outputFD int, itemID string) error {
payload := YoinkifyRequest{
URL: spotifyURL,
Format: "flac",
GenreSource: "spotify",
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to encode Yoinkify request: %w", err)
}
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deezerYoinkifyURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create Yoinkify request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := GetDownloadClient().Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("failed to call Yoinkify: %w", err)
}
defer resp.Body.Close()
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
bodyText := strings.TrimSpace(string(bodyBytes))
if bodyText != "" {
return fmt.Errorf("Yoinkify returned status %d: %s", resp.StatusCode, bodyText)
}
return fmt.Errorf("Yoinkify returned status %d", resp.StatusCode)
}
if strings.Contains(contentType, "application/json") {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
bodyText := strings.TrimSpace(string(bodyBytes))
if bodyText == "" {
bodyText = "empty JSON payload"
}
return fmt.Errorf("Yoinkify returned JSON instead of audio: %s", bodyText)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return err
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush output: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close output: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[Deezer] Downloaded via Yoinkify: %.2f MB\n", float64(written)/(1024*1024))
return nil
}
func resolveDeezerTrackURL(req DownloadRequest) (string, error) { func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
deezerID := strings.TrimSpace(req.DeezerID) deezerID := strings.TrimSpace(req.DeezerID)
if deezerID == "" { if deezerID == "" {
@@ -204,14 +55,13 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
} }
if deezerID != "" { if deezerID != "" {
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID) trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
if err := verifyDeezerTrack(req, deezerID); err != nil { if err := verifyDeezerTrack(req, deezerID, false); err != nil {
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err) GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
// Don't reject direct IDs from request payload — they're presumably correct. // Don't reject direct IDs from request payload — they're presumably correct.
} }
return trackURL, nil return trackURL, nil
} }
// Try SongLink
spotifyID := strings.TrimSpace(req.SpotifyID) spotifyID := strings.TrimSpace(req.SpotifyID)
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) { if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
songlink := NewSongLinkClient() songlink := NewSongLinkClient()
@@ -219,7 +69,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
if err == nil && availability.Deezer && availability.DeezerURL != "" { if err == nil && availability.Deezer && availability.DeezerURL != "" {
resolvedID := extractDeezerIDFromURL(availability.DeezerURL) resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
if resolvedID != "" { if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil { if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr) GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
// Fall through to ISRC search instead of using wrong track. // Fall through to ISRC search instead of using wrong track.
} else { } else {
@@ -231,7 +81,6 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
} }
} }
// Try ISRC
isrc := strings.TrimSpace(req.ISRC) isrc := strings.TrimSpace(req.ISRC)
if isrc != "" { if isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
@@ -240,7 +89,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
if err == nil && track != nil { if err == nil && track != nil {
resolvedID := songLinkExtractDeezerTrackID(track) resolvedID := songLinkExtractDeezerTrackID(track)
if resolvedID != "" { if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil { if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr) GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr) return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
} }
@@ -252,7 +101,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
return "", fmt.Errorf("could not resolve Deezer track URL") return "", fmt.Errorf("could not resolve Deezer track URL")
} }
func verifyDeezerTrack(req DownloadRequest, deezerID string) error { func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel() defer cancel()
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID) trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
@@ -260,9 +109,11 @@ func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
return nil // Can't verify — don't block the download. return nil // Can't verify — don't block the download.
} }
resolved := resolvedTrackInfo{ resolved := resolvedTrackInfo{
Title: trackResp.Track.Name, Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists, ArtistName: trackResp.Track.Artists,
Duration: trackResp.Track.DurationMS / 1000, ISRC: trackResp.Track.ISRC,
Duration: trackResp.Track.DurationMS / 1000,
SkipNameVerification: skipNameVerification,
} }
if !trackMatchesRequest(req, resolved, "Deezer") { if !trackMatchesRequest(req, resolved, "Deezer") {
return fmt.Errorf("expected '%s - %s', got '%s - %s'", return fmt.Errorf("expected '%s - %s', got '%s - %s'",
@@ -292,7 +143,6 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err
return "", fmt.Errorf("failed to create MusicDL request: %w", err) return "", fmt.Errorf("failed to create MusicDL request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
@@ -477,41 +327,29 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
) )
}() }()
// Try MusicDL first (better quality), fallback to Yoinkify
var downloadErr error
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req) deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
if deezerURLErr == nil { if deezerURLErr != nil {
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL) return DeezerDownloadResult{}, fmt.Errorf(
downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID) "deezer download failed: could not resolve Deezer URL: %w",
if downloadErr != nil { deezerURLErr,
if errors.Is(downloadErr, ErrDownloadCancelled) { )
return DeezerDownloadResult{}, ErrDownloadCancelled
}
GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr)
}
} else {
GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr)
} }
if downloadErr != nil || deezerURLErr != nil { GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
spotifyURL, err := resolveSpotifyURLForYoinkify(req) downloadErr := deezerClient.DownloadFromMusicDL(
if err != nil { deezerTrackURL,
if deezerURLErr != nil { outputPath,
return DeezerDownloadResult{}, fmt.Errorf( req.OutputFD,
"deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w", req.ItemID,
deezerURLErr, )
err, if downloadErr != nil {
) if errors.Is(downloadErr, ErrDownloadCancelled) {
} return DeezerDownloadResult{}, ErrDownloadCancelled
return DeezerDownloadResult{}, err
}
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
if downloadErr != nil {
if errors.Is(downloadErr, ErrDownloadCancelled) {
return DeezerDownloadResult{}, ErrDownloadCancelled
}
return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr)
} }
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed via MusicDL: %w",
downloadErr,
)
} }
<-parallelDone <-parallelDone
@@ -522,18 +360,21 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
} }
metadata := Metadata{ metadata := Metadata{
Title: req.TrackName, Title: req.TrackName,
Artist: req.ArtistName, Artist: req.ArtistName,
Album: req.AlbumName, Album: req.AlbumName,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate, ArtistTagMode: req.ArtistTagMode,
TrackNumber: req.TrackNumber, Date: req.ReleaseDate,
TotalTracks: req.TotalTracks, TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber, TotalTracks: req.TotalTracks,
ISRC: req.ISRC, DiscNumber: req.DiscNumber,
Genre: req.Genre, TotalDiscs: req.TotalDiscs,
Label: req.Label, ISRC: req.ISRC,
Copyright: req.Copyright, Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Composer: req.Composer,
} }
var coverData []byte var coverData []byte
-3
View File
@@ -25,7 +25,6 @@ var (
) )
func GetISRCIndex(outputDir string) *ISRCIndex { func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first
isrcIndexCacheMu.RLock() isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir] idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock() isrcIndexCacheMu.RUnlock()
@@ -34,13 +33,11 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
return idx return idx
} }
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{}) buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
mu := buildLock.(*sync.Mutex) mu := buildLock.(*sync.Mutex)
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
// Double-check cache after acquiring lock (another goroutine may have built it)
isrcIndexCacheMu.RLock() isrcIndexCacheMu.RLock()
idx, exists = isrcIndexCache[outputDir] idx, exists = isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock() isrcIndexCacheMu.RUnlock()
+767 -285
View File
File diff suppressed because it is too large Load Diff
+155
View File
@@ -113,3 +113,158 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL) t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
} }
} }
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
req := reEnrichRequest{
SpotifyID: "spotify-track-id",
AlbumName: "Original Album",
ReleaseDate: "2024-01-01",
ISRC: "REQ123",
}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
AlbumName: "Resolved Album",
ReleaseDate: "",
ISRC: "",
})
if req.ReleaseDate != "2024-01-01" {
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
}
if req.AlbumName != "Resolved Album" {
t.Fatalf("album = %q, want updated album", req.AlbumName)
}
if req.ISRC != "REQ123" {
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
}
}
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
AlbumName: "Album Name",
ReleaseDate: "",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "first",
Name: "Song Title",
Artists: "Artist Name",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "",
ProviderID: "spotify",
},
{
ID: "second",
Name: "Song Title",
Artists: "Artist Name",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "2024-03-09",
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected a selected track")
}
if best.ID != "second" {
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
}
}
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song",
ArtistName: "Artist",
AlbumName: "Album",
AlbumArtist: "",
ReleaseDate: "",
TrackNumber: 0,
DiscNumber: 0,
ISRC: "",
Genre: "",
Label: "",
Copyright: "",
}
metadata := buildReEnrichFFmpegMetadata(&req, "")
// Title and Artist are never written by re-enrich (they are search keys
// preserved as-is from the file).
if _, exists := metadata["TITLE"]; exists {
t.Fatalf("TITLE should not be in metadata: %#v", metadata)
}
if _, exists := metadata["ARTIST"]; exists {
t.Fatalf("ARTIST should not be in metadata: %#v", metadata)
}
if metadata["ALBUM"] != "Album" {
t.Fatalf("album = %q", metadata["ALBUM"])
}
for _, key := range []string{
"ALBUMARTIST",
"DATE",
"TRACKNUMBER",
"DISCNUMBER",
"ISRC",
"GENRE",
"ORGANIZATION",
"COPYRIGHT",
"LYRICS",
"UNSYNCEDLYRICS",
} {
if _, exists := metadata[key]; exists {
t.Fatalf("did not expect key %s in metadata: %#v", key, metadata)
}
}
}
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
req := reEnrichRequest{}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
TrackNumber: 7,
TotalTracks: 12,
DiscNumber: 2,
TotalDiscs: 3,
Composer: "Composer",
})
if req.TrackNumber != 7 || req.TotalTracks != 12 {
t.Fatalf("track metadata = %d/%d", req.TrackNumber, req.TotalTracks)
}
if req.DiscNumber != 2 || req.TotalDiscs != 3 {
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
}
if req.Composer != "Composer" {
t.Fatalf("composer = %q", req.Composer)
}
}
func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) {
req := reEnrichRequest{
TrackNumber: 7,
TotalTracks: 12,
DiscNumber: 2,
TotalDiscs: 3,
Composer: "Composer",
}
metadata := buildReEnrichFFmpegMetadata(&req, "")
if metadata["TRACKNUMBER"] != "7/12" {
t.Fatalf("TRACKNUMBER = %q", metadata["TRACKNUMBER"])
}
if metadata["DISCNUMBER"] != "2/3" {
t.Fatalf("DISCNUMBER = %q", metadata["DISCNUMBER"])
}
if metadata["COMPOSER"] != "Composer" {
t.Fatalf("COMPOSER = %q", metadata["COMPOSER"])
}
}
+47 -44
View File
@@ -43,12 +43,12 @@ func compareVersions(v1, v2 string) int {
return 0 return 0
} }
type LoadedExtension struct { type loadedExtension struct {
ID string `json:"id"` ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"` Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"` VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"` VMMu sync.Mutex `json:"-"`
runtime *ExtensionRuntime runtime *extensionRuntime
initialized bool initialized bool
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
@@ -73,7 +73,7 @@ func getExtensionInitSettings(extensionID string) map[string]interface{} {
return filtered return filtered
} }
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error { func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
if ext.VM == nil || ext.runtime == nil { if ext.VM == nil || ext.runtime == nil {
if err := initializeVMLocked(ext); err != nil { if err := initializeVMLocked(ext); err != nil {
ext.Error = err.Error() ext.Error = err.Error()
@@ -100,14 +100,14 @@ func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) er
return nil return nil
} }
func (ext *LoadedExtension) ensureRuntimeReady() error { func (ext *loadedExtension) ensureRuntimeReady() error {
ext.VMMu.Lock() ext.VMMu.Lock()
defer ext.VMMu.Unlock() defer ext.VMMu.Unlock()
return ensureRuntimeReadyLocked(ext, true) return ensureRuntimeReadyLocked(ext, true)
} }
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) { func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
ext.VMMu.Lock() ext.VMMu.Lock()
if err := ensureRuntimeReadyLocked(ext, true); err != nil { if err := ensureRuntimeReadyLocked(ext, true); err != nil {
ext.VMMu.Unlock() ext.VMMu.Unlock()
@@ -116,28 +116,28 @@ func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
return ext.VM, nil return ext.VM, nil
} }
type ExtensionManager struct { type extensionManager struct {
mu sync.RWMutex mu sync.RWMutex
extensions map[string]*LoadedExtension extensions map[string]*loadedExtension
extensionsDir string extensionsDir string
dataDir string dataDir string
} }
var ( var (
globalExtManager *ExtensionManager globalExtManager *extensionManager
globalExtManagerOnce sync.Once globalExtManagerOnce sync.Once
) )
func GetExtensionManager() *ExtensionManager { func getExtensionManager() *extensionManager {
globalExtManagerOnce.Do(func() { globalExtManagerOnce.Do(func() {
globalExtManager = &ExtensionManager{ globalExtManager = &extensionManager{
extensions: make(map[string]*LoadedExtension), extensions: make(map[string]*loadedExtension),
} }
}) })
return globalExtManager return globalExtManager
} }
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error { func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -154,7 +154,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
return nil return nil
} }
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) { func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
} }
@@ -272,7 +272,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return nil, fmt.Errorf("failed to create extension data directory: %w", err) return nil, fmt.Errorf("failed to create extension data directory: %w", err)
} }
ext := &LoadedExtension{ ext := &loadedExtension{
ID: manifest.Name, ID: manifest.Name,
Manifest: manifest, Manifest: manifest,
Enabled: false, // New extensions start disabled Enabled: false, // New extensions start disabled
@@ -292,7 +292,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return ext, nil return ext, nil
} }
func initializeVMLocked(ext *LoadedExtension) error { func initializeVMLocked(ext *loadedExtension) error {
ext.VM = nil ext.VM = nil
ext.runtime = nil ext.runtime = nil
ext.initialized = false ext.initialized = false
@@ -305,7 +305,7 @@ func initializeVMLocked(ext *LoadedExtension) error {
return fmt.Errorf("failed to read index.js: %w", err) return fmt.Errorf("failed to read index.js: %w", err)
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
ext.runtime = runtime ext.runtime = runtime
runtime.RegisterAPIs(vm) runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm) runtime.RegisterGoBackendAPIs(vm)
@@ -342,14 +342,14 @@ func initializeVMLocked(ext *LoadedExtension) error {
return nil return nil
} }
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { func (m *extensionManager) initializeVM(ext *loadedExtension) error {
ext.VMMu.Lock() ext.VMMu.Lock()
defer ext.VMMu.Unlock() defer ext.VMMu.Unlock()
return initializeVMLocked(ext) return initializeVMLocked(ext)
} }
func initializeExtensionWithSettingsLocked( func initializeExtensionWithSettingsLocked(
ext *LoadedExtension, ext *loadedExtension,
settings map[string]interface{}, settings map[string]interface{},
) error { ) error {
if ext.VM == nil { if ext.VM == nil {
@@ -405,7 +405,7 @@ func initializeExtensionWithSettingsLocked(
return nil return nil
} }
func runCleanupLocked(ext *LoadedExtension) error { func runCleanupLocked(ext *loadedExtension) error {
if ext.VM != nil { if ext.VM != nil {
script := ` script := `
(function() { (function() {
@@ -446,7 +446,7 @@ func runCleanupLocked(ext *LoadedExtension) error {
return nil return nil
} }
func teardownVMLocked(ext *LoadedExtension) { func teardownVMLocked(ext *loadedExtension) {
if err := runCleanupLocked(ext); err != nil { if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err) GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
} }
@@ -461,7 +461,7 @@ func teardownVMLocked(ext *LoadedExtension) {
ext.initialized = false ext.initialized = false
} }
func validateExtensionLoad(ext *LoadedExtension) error { func validateExtensionLoad(ext *loadedExtension) error {
ext.VMMu.Lock() ext.VMMu.Lock()
defer ext.VMMu.Unlock() defer ext.VMMu.Unlock()
@@ -472,7 +472,7 @@ func validateExtensionLoad(ext *LoadedExtension) error {
return nil return nil
} }
func (m *ExtensionManager) UnloadExtension(extensionID string) error { func (m *extensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -491,7 +491,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return nil return nil
} }
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) { func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -502,18 +502,18 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
return ext, nil return ext, nil
} }
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension { func (m *extensionManager) GetAllExtensions() []*loadedExtension {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
result := make([]*LoadedExtension, 0, len(m.extensions)) result := make([]*loadedExtension, 0, len(m.extensions))
for _, ext := range m.extensions { for _, ext := range m.extensions {
result = append(result, ext) result = append(result, ext)
} }
return result return result
} }
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error { func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -547,7 +547,7 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return nil return nil
} }
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) { func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
var loaded []string var loaded []string
var errors []error var errors []error
@@ -585,7 +585,7 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
return loaded, errors return loaded, errors
} }
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) { func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedExtension, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -615,7 +615,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return nil, fmt.Errorf("failed to create extension data directory: %w", err) return nil, fmt.Errorf("failed to create extension data directory: %w", err)
} }
ext := &LoadedExtension{ ext := &loadedExtension{
ID: manifest.Name, ID: manifest.Name,
Manifest: manifest, Manifest: manifest,
Enabled: false, // Will be restored from settings store Enabled: false, // Will be restored from settings store
@@ -643,7 +643,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return ext, nil return ext, nil
} }
func (m *ExtensionManager) RemoveExtension(extensionID string) error { func (m *extensionManager) RemoveExtension(extensionID string) error {
ext, err := m.GetExtension(extensionID) ext, err := m.GetExtension(extensionID)
if err != nil { if err != nil {
return err return err
@@ -663,7 +663,7 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
} }
// Only allows upgrades (new version > current version), not downgrades // Only allows upgrades (new version > current version), not downgrades
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) { func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
} }
@@ -777,7 +777,7 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
} }
} }
ext := &LoadedExtension{ ext := &loadedExtension{
ID: newManifest.Name, ID: newManifest.Name,
Manifest: newManifest, Manifest: newManifest,
Enabled: wasEnabled, // Preserve enabled state from before upgrade Enabled: wasEnabled, // Preserve enabled state from before upgrade
@@ -812,7 +812,7 @@ type ExtensionUpgradeInfo struct {
IsInstalled bool `json:"is_installed"` IsInstalled bool `json:"is_installed"`
} }
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) { func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
} }
@@ -871,7 +871,7 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
return info, nil return info, nil
} }
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) { func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
info, err := m.checkExtensionUpgradeInternal(filePath) info, err := m.checkExtensionUpgradeInternal(filePath)
if err != nil { if err != nil {
return "", err return "", err
@@ -885,7 +885,7 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
return string(jsonBytes), nil return string(jsonBytes), nil
} }
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions() extensions := m.GetAllExtensions()
type ExtensionInfo struct { type ExtensionInfo struct {
@@ -908,6 +908,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
HasDownloadProvider bool `json:"has_download_provider"` HasDownloadProvider bool `json:"has_download_provider"`
HasLyricsProvider bool `json:"has_lyrics_provider"` HasLyricsProvider bool `json:"has_lyrics_provider"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
SkipLyrics bool `json:"skip_lyrics"`
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"` SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"` TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"` PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
@@ -965,6 +966,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
HasDownloadProvider: ext.Manifest.IsDownloadProvider(), HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
HasLyricsProvider: ext.Manifest.IsLyricsProvider(), HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment, SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
SkipLyrics: ext.Manifest.SkipLyrics,
SearchBehavior: ext.Manifest.SearchBehavior, SearchBehavior: ext.Manifest.SearchBehavior,
TrackMatching: ext.Manifest.TrackMatching, TrackMatching: ext.Manifest.TrackMatching,
PostProcessing: ext.Manifest.PostProcessing, PostProcessing: ext.Manifest.PostProcessing,
@@ -980,7 +982,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error { func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -998,7 +1000,7 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return initializeExtensionWithSettingsLocked(ext, settings) return initializeExtensionWithSettingsLocked(ext, settings)
} }
func (m *ExtensionManager) CleanupExtension(extensionID string) error { func (m *extensionManager) CleanupExtension(extensionID string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -1020,7 +1022,7 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
return nil return nil
} }
func (m *ExtensionManager) UnloadAllExtensions() { func (m *extensionManager) UnloadAllExtensions() {
m.mu.Lock() m.mu.Lock()
extensionIDs := make([]string, 0, len(m.extensions)) extensionIDs := make([]string, 0, len(m.extensions))
for id := range m.extensions { for id := range m.extensions {
@@ -1035,7 +1037,7 @@ func (m *ExtensionManager) UnloadAllExtensions() {
GoLog("[Extension] All extensions unloaded\n") GoLog("[Extension] All extensions unloaded\n")
} }
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) { func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -1044,13 +1046,14 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
return nil, fmt.Errorf("extension not found: %s", extensionID) return nil, fmt.Errorf("extension not found: %s", extensionID)
} }
if err := ext.ensureRuntimeReady(); err != nil {
return nil, err
}
if !ext.Enabled { if !ext.Enabled {
return nil, fmt.Errorf("extension is disabled") return nil, fmt.Errorf("extension is disabled")
} }
vm, err := ext.lockReadyVM()
if err != nil {
return nil, err
}
defer ext.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
@@ -1070,7 +1073,7 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
})() })()
`, actionName, actionName, actionName) `, actionName, actionName, actionName)
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout) result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
if err != nil { if err != nil {
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err) GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
return nil, fmt.Errorf("action failed: %v", err) return nil, fmt.Errorf("action failed: %v", err)
+1
View File
@@ -115,6 +115,7 @@ type ExtensionManifest struct {
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"` MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipLyrics bool `json:"skipLyrics,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
+124 -64
View File
@@ -26,7 +26,9 @@ type ExtTrackMetadata struct {
Images string `json:"images,omitempty"` Images string `json:"images,omitempty"`
ReleaseDate string `json:"release_date,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"` TrackNumber int `json:"track_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
ProviderID string `json:"provider_id"` ProviderID string `json:"provider_id"`
ItemType string `json:"item_type,omitempty"` ItemType string `json:"item_type,omitempty"`
@@ -41,6 +43,7 @@ type ExtTrackMetadata struct {
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"` Copyright string `json:"copyright,omitempty"`
Genre string `json:"genre,omitempty"` Genre string `json:"genre,omitempty"`
Composer string `json:"composer,omitempty"`
} }
func (t *ExtTrackMetadata) ResolvedCoverURL() string { func (t *ExtTrackMetadata) ResolvedCoverURL() string {
@@ -113,19 +116,19 @@ type ExtDownloadResult struct {
DecryptionKey string `json:"decryption_key,omitempty"` DecryptionKey string `json:"decryption_key,omitempty"`
} }
type ExtensionProviderWrapper struct { type extensionProviderWrapper struct {
extension *LoadedExtension extension *loadedExtension
vm *goja.Runtime vm *goja.Runtime
} }
func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper { func newExtensionProviderWrapper(ext *loadedExtension) *extensionProviderWrapper {
return &ExtensionProviderWrapper{ return &extensionProviderWrapper{
extension: ext, extension: ext,
vm: ext.VM, vm: ext.VM,
} }
} }
func (p *ExtensionProviderWrapper) lockReadyVM() error { func (p *extensionProviderWrapper) lockReadyVM() error {
vm, err := p.extension.lockReadyVM() vm, err := p.extension.lockReadyVM()
if err != nil { if err != nil {
return err return err
@@ -134,7 +137,7 @@ func (p *ExtensionProviderWrapper) lockReadyVM() error {
return nil return nil
} }
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) { func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
} }
@@ -194,7 +197,7 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return &searchResult, nil return &searchResult, nil
} }
func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) { func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
} }
@@ -243,7 +246,7 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
return &track, nil return &track, nil
} }
func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) { func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
} }
@@ -295,7 +298,7 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
return &album, nil return &album, nil
} }
func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) { func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
} }
@@ -350,7 +353,7 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return &artist, nil return &artist, nil
} }
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) { func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return track, nil return track, nil
} }
@@ -412,7 +415,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return &enrichedTrack, nil return &enrichedTrack, nil
} }
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) { func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) {
if !p.extension.Manifest.IsDownloadProvider() { if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
} }
@@ -460,7 +463,7 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
return &availability, nil return &availability, nil
} }
func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) { func (p *extensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
if !p.extension.Manifest.IsDownloadProvider() { if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
} }
@@ -510,7 +513,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
const ExtDownloadTimeout = DownloadTimeout const ExtDownloadTimeout = DownloadTimeout
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) { func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() { if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
} }
@@ -526,6 +529,10 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
}, nil }, nil
} }
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
if p.extension.runtime != nil {
p.extension.runtime.setActiveDownloadItemID(itemID)
defer p.extension.runtime.clearActiveDownloadItemID()
}
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 { if len(call.Arguments) > 0 {
@@ -597,40 +604,40 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
return &downloadResult, nil return &downloadResult, nil
} }
func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper { func (m *extensionManager) GetMetadataProviders() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.IsMetadataProvider() && ext.Error == "" { if ext.Enabled && ext.Manifest.IsMetadataProvider() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
return providers return providers
} }
func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper { func (m *extensionManager) GetDownloadProviders() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.IsDownloadProvider() && ext.Error == "" { if ext.Enabled && ext.Manifest.IsDownloadProvider() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
return providers return providers
} }
func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) { func (m *extensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
providers := m.GetMetadataProviders() providers := m.GetMetadataProviders()
if len(providers) == 0 { if len(providers) == 0 {
return nil, nil return nil, nil
} }
providerByID := make(map[string]*ExtensionProviderWrapper, len(providers)) providerByID := make(map[string]*extensionProviderWrapper, len(providers))
orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers)) orderedProviders := make([]*extensionProviderWrapper, 0, len(providers))
for _, provider := range providers { for _, provider := range providers {
providerByID[provider.extension.ID] = provider providerByID[provider.extension.ID] = provider
} }
@@ -771,7 +778,9 @@ func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTr
Images: track.Images, Images: track.Images,
ReleaseDate: track.ReleaseDate, ReleaseDate: track.ReleaseDate,
TrackNumber: track.TrackNumber, TrackNumber: track.TrackNumber,
TotalTracks: track.TotalTracks,
DiscNumber: track.DiscNumber, DiscNumber: track.DiscNumber,
TotalDiscs: track.TotalDiscs,
ISRC: track.ISRC, ISRC: track.ISRC,
ProviderID: providerID, ProviderID: providerID,
SpotifyID: prefixedID, SpotifyID: prefixedID,
@@ -779,6 +788,7 @@ func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTr
TidalID: tidalID, TidalID: tidalID,
QobuzID: qobuzID, QobuzID: qobuzID,
AlbumType: track.AlbumType, AlbumType: track.AlbumType,
Composer: track.Composer,
} }
} }
@@ -820,13 +830,13 @@ func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrac
} }
} }
func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) { func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
priority := GetMetadataProviderPriority() priority := GetMetadataProviderPriority()
if limit <= 0 { if limit <= 0 {
limit = 20 limit = 20
} }
extensionProviders := make(map[string]*ExtensionProviderWrapper) extensionProviders := make(map[string]*extensionProviderWrapper)
if includeExtensions { if includeExtensions {
for _, provider := range m.GetMetadataProviders() { for _, provider := range m.GetMetadataProviders() {
extensionProviders[provider.extension.ID] = provider extensionProviders[provider.extension.ID] = provider
@@ -906,7 +916,7 @@ func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
priority := GetProviderPriority() priority := GetProviderPriority()
extManager := GetExtensionManager() extManager := getExtensionManager()
strictMode := !req.UseFallback strictMode := !req.UseFallback
selectedProvider := strings.TrimSpace(req.Service) selectedProvider := strings.TrimSpace(req.Service)
@@ -961,7 +971,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() { if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source) GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
provider := NewExtensionProviderWrapper(ext) provider := newExtensionProviderWrapper(ext)
trackMeta := &ExtTrackMetadata{ trackMeta := &ExtTrackMetadata{
ID: req.SpotifyID, ID: req.SpotifyID,
Name: req.TrackName, Name: req.TrackName,
@@ -971,8 +981,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ISRC: req.ISRC, ISRC: req.ISRC,
ReleaseDate: req.ReleaseDate, ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber, TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, DiscNumber: req.DiscNumber,
TotalDiscs: req.TotalDiscs,
ProviderID: req.Source, ProviderID: req.Source,
Composer: req.Composer,
} }
enrichedTrack, err := provider.EnrichTrack(trackMeta) enrichedTrack, err := provider.EnrichTrack(trackMeta)
@@ -1033,13 +1046,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate) GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate)
req.ReleaseDate = enrichedTrack.ReleaseDate req.ReleaseDate = enrichedTrack.ReleaseDate
} }
if enrichedTrack.TrackNumber > 0 && req.TrackNumber == 0 {
GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", enrichedTrack.TrackNumber)
req.TrackNumber = enrichedTrack.TrackNumber
}
if enrichedTrack.TotalTracks > 0 && req.TotalTracks == 0 {
GoLog("[DownloadWithExtensionFallback] TotalTracks from enrichment: %d\n", enrichedTrack.TotalTracks)
req.TotalTracks = enrichedTrack.TotalTracks
}
if enrichedTrack.DiscNumber > 0 && req.DiscNumber == 0 {
GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", enrichedTrack.DiscNumber)
req.DiscNumber = enrichedTrack.DiscNumber
}
if enrichedTrack.TotalDiscs > 0 && req.TotalDiscs == 0 {
GoLog("[DownloadWithExtensionFallback] TotalDiscs from enrichment: %d\n", enrichedTrack.TotalDiscs)
req.TotalDiscs = enrichedTrack.TotalDiscs
}
if enrichedTrack.Composer != "" && req.Composer == "" {
GoLog("[DownloadWithExtensionFallback] Composer from enrichment: %s\n", enrichedTrack.Composer)
req.Composer = enrichedTrack.Composer
}
} }
} }
} }
// If key metadata is still missing after extension enrichment, search
// configured metadata providers (Spotify/Deezer/Tidal/Qobuz) — same
// logic that ReEnrichFile uses.
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) && if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) &&
req.TrackName != "" && req.ArtistName != "" && req.TrackName != "" && req.ArtistName != "" &&
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") { (req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
@@ -1068,9 +1098,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if track.TrackNumber > 0 && req.TrackNumber == 0 { if track.TrackNumber > 0 && req.TrackNumber == 0 {
req.TrackNumber = track.TrackNumber req.TrackNumber = track.TrackNumber
} }
if track.TotalTracks > 0 && req.TotalTracks == 0 {
req.TotalTracks = track.TotalTracks
}
if track.DiscNumber > 0 && req.DiscNumber == 0 { if track.DiscNumber > 0 && req.DiscNumber == 0 {
req.DiscNumber = track.DiscNumber req.DiscNumber = track.DiscNumber
} }
if track.TotalDiscs > 0 && req.TotalDiscs == 0 {
req.TotalDiscs = track.TotalDiscs
}
if track.Composer != "" && req.Composer == "" {
req.Composer = track.Composer
}
if track.CoverURL != "" && req.CoverURL == "" { if track.CoverURL != "" && req.CoverURL == "" {
req.CoverURL = track.CoverURL req.CoverURL = track.CoverURL
} }
@@ -1087,7 +1126,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr) GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
} }
// Try Deezer extended metadata if we have ISRC
if req.ISRC != "" && if req.ISRC != "" &&
(req.Genre == "" || req.Label == "" || req.Copyright == "") { (req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -1117,7 +1155,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() { if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
skipBuiltIn = ext.Manifest.SkipBuiltInFallback skipBuiltIn = ext.Manifest.SkipBuiltInFallback
provider := NewExtensionProviderWrapper(ext) provider := newExtensionProviderWrapper(ext)
trackID := req.SpotifyID trackID := req.SpotifyID
@@ -1128,7 +1166,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
StartItemProgress(req.ItemID) StartItemProgress(req.ItemID)
} }
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) { result, err := provider.Download(trackID, req.Quality, outputPath, req.ItemID, func(percent int) {
if req.ItemID != "" { if req.ItemID != "" {
normalized := float64(percent) / 100.0 normalized := float64(percent) / 100.0
if normalized < 0 { if normalized < 0 {
@@ -1201,8 +1239,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
} }
// Always pass enriched metadata from req so Flutter can
// embed it — fills gaps from metadata provider search.
if req.AlbumName != "" && resp.Album == "" { if req.AlbumName != "" && resp.Album == "" {
resp.Album = req.AlbumName resp.Album = req.AlbumName
} }
@@ -1340,7 +1376,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue continue
} }
provider := NewExtensionProviderWrapper(ext) provider := newExtensionProviderWrapper(ext)
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID) availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID)
if err != nil || !availability.Available { if err != nil || !availability.Available {
@@ -1356,7 +1392,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
StartItemProgress(req.ItemID) StartItemProgress(req.ItemID)
} }
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) { result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
if req.ItemID != "" { if req.ItemID != "" {
normalized := float64(percent) / 100.0 normalized := float64(percent) / 100.0
if normalized < 0 { if normalized < 0 {
@@ -1429,6 +1465,28 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
} }
} }
if req.AlbumName != "" && resp.Album == "" {
resp.Album = req.AlbumName
}
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
resp.AlbumArtist = req.AlbumArtist
}
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
resp.ReleaseDate = req.ReleaseDate
}
if req.ISRC != "" && resp.ISRC == "" {
resp.ISRC = req.ISRC
}
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
resp.TrackNumber = req.TrackNumber
}
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
resp.DiscNumber = req.DiscNumber
}
if req.CoverURL != "" && resp.CoverURL == "" {
resp.CoverURL = req.CoverURL
}
return resp, nil return resp, nil
} }
@@ -1566,12 +1624,15 @@ func buildOutputPath(req DownloadRequest) string {
"album_artist": req.AlbumArtist, "album_artist": req.AlbumArtist,
"track": req.TrackNumber, "track": req.TrackNumber,
"track_number": req.TrackNumber, "track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"disc": req.DiscNumber, "disc": req.DiscNumber,
"disc_number": req.DiscNumber, "disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate), "year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate, "date": req.ReleaseDate,
"release_date": req.ReleaseDate, "release_date": req.ReleaseDate,
"isrc": req.ISRC, "isrc": req.ISRC,
"composer": req.Composer,
} }
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
@@ -1596,7 +1657,7 @@ func buildOutputPath(req DownloadRequest) string {
return filepath.Join(outputDir, filename+ext) return filepath.Join(outputDir, filename+ext)
} }
func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) string { func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string {
if strings.TrimSpace(req.OutputPath) != "" { if strings.TrimSpace(req.OutputPath) != "" {
return strings.TrimSpace(req.OutputPath) return strings.TrimSpace(req.OutputPath)
} }
@@ -1605,7 +1666,6 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
return buildOutputPath(req) return buildOutputPath(req)
} }
// SAF mode: use extension's data dir as writable temp location
tempDir := filepath.Join(ext.DataDir, "downloads") tempDir := filepath.Join(ext.DataDir, "downloads")
os.MkdirAll(tempDir, 0755) os.MkdirAll(tempDir, 0755)
AddAllowedDownloadDir(tempDir) AddAllowedDownloadDir(tempDir)
@@ -1617,12 +1677,15 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
"album_artist": req.AlbumArtist, "album_artist": req.AlbumArtist,
"track": req.TrackNumber, "track": req.TrackNumber,
"track_number": req.TrackNumber, "track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"disc": req.DiscNumber, "disc": req.DiscNumber,
"disc_number": req.DiscNumber, "disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate), "year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate, "date": req.ReleaseDate,
"release_date": req.ReleaseDate, "release_date": req.ReleaseDate,
"isrc": req.ISRC, "isrc": req.ISRC,
"composer": req.Composer,
} }
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
@@ -1640,7 +1703,7 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
return filepath.Join(tempDir, filename+outputExt) return filepath.Join(tempDir, filename+outputExt)
} }
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
if !p.extension.Manifest.HasCustomSearch() { if !p.extension.Manifest.HasCustomSearch() {
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
} }
@@ -1722,7 +1785,7 @@ type ExtURLHandleResult struct {
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
} }
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) { func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
if !p.extension.Manifest.HasURLHandler() { if !p.extension.Manifest.HasURLHandler() {
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
} }
@@ -1808,7 +1871,7 @@ type MatchTrackResult struct {
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
} }
func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) { func (p *extensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
if !p.extension.Manifest.HasCustomMatching() { if !p.extension.Manifest.HasCustomMatching() {
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
} }
@@ -1879,7 +1942,7 @@ type PostProcessInput struct {
const PostProcessTimeout = 2 * time.Minute const PostProcessTimeout = 2 * time.Minute
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() { if !p.extension.Manifest.HasPostProcessing() {
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
} }
@@ -1942,7 +2005,7 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return &postResult, nil return &postResult, nil
} }
func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() { if !p.extension.Manifest.HasPostProcessing() {
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
} }
@@ -2012,39 +2075,39 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
return &postResult, nil return &postResult, nil
} }
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper { func (m *extensionManager) GetSearchProviders() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" { if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
return providers return providers
} }
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper { func (m *extensionManager) GetURLHandlers() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" { if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
return providers return providers
} }
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper { func (m *extensionManager) FindURLHandler(url string) *extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" { if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" {
return NewExtensionProviderWrapper(ext) return newExtensionProviderWrapper(ext)
} }
} }
return nil return nil
@@ -2055,7 +2118,7 @@ type ExtURLHandleResultWithExtID struct {
ExtensionID string ExtensionID string
} }
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) { func (m *extensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
handler := m.FindURLHandler(url) handler := m.FindURLHandler(url)
if handler == nil { if handler == nil {
return nil, fmt.Errorf("no extension found to handle URL: %s", url) return nil, fmt.Errorf("no extension found to handle URL: %s", url)
@@ -2075,20 +2138,20 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu
}, nil }, nil
} }
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper { func (m *extensionManager) GetPostProcessingProviders() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.HasPostProcessing() && ext.Error == "" { if ext.Enabled && ext.Manifest.HasPostProcessing() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
return providers return providers
} }
func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) { func (m *extensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders() providers := m.GetPostProcessingProviders()
if len(providers) == 0 { if len(providers) == 0 {
return &PostProcessResult{Success: true, NewFilePath: filePath}, nil return &PostProcessResult{Success: true, NewFilePath: filePath}, nil
@@ -2133,7 +2196,7 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
} }
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) { func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders() providers := m.GetPostProcessingProviders()
if len(providers) == 0 { if len(providers) == 0 {
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
@@ -2201,7 +2264,7 @@ type ExtLyricsLine struct {
EndTimeMs int64 `json:"endTimeMs"` EndTimeMs int64 `json:"endTimeMs"`
} }
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) { func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
if !p.extension.Manifest.IsLyricsProvider() { if !p.extension.Manifest.IsLyricsProvider() {
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
} }
@@ -2263,7 +2326,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
return nil, fmt.Errorf("failed to parse lyrics result: %w", err) return nil, fmt.Errorf("failed to parse lyrics result: %w", err)
} }
// Convert ExtLyricsResult to LyricsResponse
response := &LyricsResponse{ response := &LyricsResponse{
SyncType: extResult.SyncType, SyncType: extResult.SyncType,
Instrumental: extResult.Instrumental, Instrumental: extResult.Instrumental,
@@ -2284,7 +2346,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
}) })
} }
// If the extension provided plainLyrics but no lines, parse them as unsynced
if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental { if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental {
response.SyncType = "UNSYNCED" response.SyncType = "UNSYNCED"
for _, line := range strings.Split(response.PlainLyrics, "\n") { for _, line := range strings.Split(response.PlainLyrics, "\n") {
@@ -2301,18 +2362,17 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
return response, nil return response, nil
} }
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper { func (m *extensionManager) GetLyricsProviders() []*extensionProviderWrapper {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var providers []*ExtensionProviderWrapper var providers []*extensionProviderWrapper
for _, ext := range m.extensions { for _, ext := range m.extensions {
if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" { if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" {
providers = append(providers, NewExtensionProviderWrapper(ext)) providers = append(providers, newExtensionProviderWrapper(ext))
} }
} }
// Keep a deterministic order so provider selection is stable across runs.
sort.Slice(providers, func(i, j int) bool { sort.Slice(providers, func(i, j int) bool {
return providers[i].extension.ID < providers[j].extension.ID return providers[i].extension.ID < providers[j].extension.ID
}) })
+1 -1
View File
@@ -51,7 +51,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
} }
} }
manager := GetExtensionManager() manager := getExtensionManager()
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false) tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
if err != nil { if err != nil {
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err) t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
+27 -6
View File
@@ -80,7 +80,7 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
state.IsAuthenticated = accessToken != "" state.IsAuthenticated = accessToken != ""
} }
type ExtensionRuntime struct { type extensionRuntime struct {
extensionID string extensionID string
manifest *ExtensionManifest manifest *ExtensionManifest
settings map[string]interface{} settings map[string]interface{}
@@ -90,6 +90,9 @@ type ExtensionRuntime struct {
dataDir string dataDir string
vm *goja.Runtime vm *goja.Runtime
activeDownloadMu sync.RWMutex
activeDownloadItemID string
storageMu sync.RWMutex storageMu sync.RWMutex
storageCache map[string]interface{} storageCache map[string]interface{}
storageLoaded bool storageLoaded bool
@@ -120,10 +123,10 @@ var (
privateIPCacheMu sync.RWMutex privateIPCacheMu sync.RWMutex
) )
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
jar, _ := newSimpleCookieJar() jar, _ := newSimpleCookieJar()
runtime := &ExtensionRuntime{ runtime := &extensionRuntime{
extensionID: ext.ID, extensionID: ext.ID,
manifest: ext.Manifest, manifest: ext.Manifest,
settings: make(map[string]interface{}), settings: make(map[string]interface{}),
@@ -139,7 +142,25 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
return runtime return runtime
} }
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client { func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
r.activeDownloadItemID = strings.TrimSpace(itemID)
}
func (r *extensionRuntime) clearActiveDownloadItemID() {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
r.activeDownloadItemID = ""
}
func (r *extensionRuntime) getActiveDownloadItemID() string {
r.activeDownloadMu.RLock()
defer r.activeDownloadMu.RUnlock()
return r.activeDownloadItemID
}
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global // Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g. // allow_http scheme downgrade here, because some extension APIs (e.g.
// spotify-web) will redirect http -> https and can end up in 301 loops. // spotify-web) will redirect http -> https and can end up in 301 loops.
@@ -308,11 +329,11 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
return j.cookies[u.Host] return j.cookies[u.Host]
} }
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings r.settings = settings
} }
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
r.vm = vm r.vm = vm
httpObj := vm.NewObject() httpObj := vm.NewObject()
+10 -14
View File
@@ -52,7 +52,7 @@ func summarizeURLForLog(urlStr string) string {
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path) 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{}{
"success": false, "success": false,
@@ -99,7 +99,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -111,7 +111,7 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(state.AuthCode) return r.vm.ToValue(state.AuthCode)
} }
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -149,7 +149,7 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
delete(extensionAuthState, r.extensionID) delete(extensionAuthState, r.extensionID)
extensionAuthStateMu.Unlock() extensionAuthStateMu.Unlock()
@@ -162,7 +162,7 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -178,7 +178,7 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(state.IsAuthenticated) return r.vm.ToValue(state.IsAuthenticated)
} }
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -201,7 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result) return r.vm.ToValue(result)
} }
// Length should be between 43-128 characters (RFC 7636)
func generatePKCEVerifier(length int) (string, error) { func generatePKCEVerifier(length int) (string, error) {
if length < 43 { if length < 43 {
length = 43 length = 43
@@ -226,11 +225,10 @@ func generatePKCEVerifier(length int) (string, error) {
func generatePKCEChallenge(verifier string) string { func generatePKCEChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier)) hash := sha256.Sum256([]byte(verifier))
// Base64url encode without padding (RFC 7636)
return base64.RawURLEncoding.EncodeToString(hash[:]) return base64.RawURLEncoding.EncodeToString(hash[:])
} }
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
length := 64 length := 64
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 { if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
@@ -267,7 +265,7 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -283,8 +281,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
}) })
} }
// config: { authUrl, clientId, redirectUri, scope, extraParams } func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) authStartOAuthWithPKCE(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{}{
"success": false, "success": false,
@@ -388,8 +385,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
}) })
} }
// config: { tokenUrl, clientId, redirectUri, code, extraParams } func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(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{}{
"success": false, "success": false,
+3 -3
View File
@@ -50,7 +50,7 @@ func ClearFFmpegCommand(commandID string) {
delete(ffmpegCommands, commandID) delete(ffmpegCommands, commandID)
} }
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -107,7 +107,7 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
} }
} }
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -134,7 +134,7 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
+25 -10
View File
@@ -71,7 +71,7 @@ func isPathWithinBase(baseDir, targetPath string) bool {
return true return true
} }
func (r *ExtensionRuntime) validatePath(path string) (string, error) { func (r *extensionRuntime) validatePath(path string) (string, error) {
if !r.manifest.Permissions.File { if !r.manifest.Permissions.File {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission") return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
} }
@@ -106,7 +106,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return absPath, nil return absPath, nil
} }
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -205,13 +205,22 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
defer out.Close() defer out.Close()
contentLength := resp.ContentLength contentLength := resp.ContentLength
activeItemID := r.getActiveDownloadItemID()
if activeItemID != "" && contentLength > 0 {
SetItemBytesTotal(activeItemID, contentLength)
}
var progressWriter interface{ Write([]byte) (int, error) } = out
if activeItemID != "" {
progressWriter = NewItemProgressWriter(out, activeItemID)
}
var written int64 var written int64
buf := make([]byte, 32*1024) buf := make([]byte, 32*1024)
for { for {
nr, er := resp.Body.Read(buf) nr, er := resp.Body.Read(buf)
if nr > 0 { if nr > 0 {
nw, ew := out.Write(buf[0:nr]) nw, ew := progressWriter.Write(buf[0:nr])
if nw < 0 || nr < nw { if nw < 0 || nr < nw {
nw = 0 nw = 0
if ew == nil { if ew == nil {
@@ -220,6 +229,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
} }
written += int64(nw) written += int64(nw)
if ew != nil { if ew != nil {
if ew == ErrDownloadCancelled {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "download cancelled",
})
}
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
"error": fmt.Sprintf("failed to write file: %v", ew), "error": fmt.Sprintf("failed to write file: %v", ew),
@@ -256,7 +271,7 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -271,7 +286,7 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(err == nil) return r.vm.ToValue(err == nil)
} }
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -300,7 +315,7 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -331,7 +346,7 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -371,7 +386,7 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -444,7 +459,7 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -492,7 +507,7 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
+13 -9
View File
@@ -17,7 +17,7 @@ type HTTPResponse struct {
Headers map[string]string `json:"headers"` Headers map[string]string `json:"headers"`
} }
func (r *ExtensionRuntime) validateDomain(urlStr string) error { func (r *extensionRuntime) validateDomain(urlStr string) error {
parsed, err := url.Parse(urlStr) parsed, err := url.Parse(urlStr)
if err != nil { if err != nil {
return fmt.Errorf("invalid URL: %w", err) return fmt.Errorf("invalid URL: %w", err)
@@ -49,7 +49,7 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
return nil return nil
} }
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"error": "URL is required", "error": "URL is required",
@@ -118,12 +118,13 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
}) })
} }
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"error": "URL is required", "error": "URL is required",
@@ -214,12 +215,13 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
}) })
} }
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"error": "URL is required", "error": "URL is required",
@@ -322,24 +324,25 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
}) })
} }
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call) return r.httpMethodShortcut("PUT", call)
} }
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call) return r.httpMethodShortcut("DELETE", call)
} }
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call) return r.httpMethodShortcut("PATCH", call)
} }
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"error": "URL is required", "error": "URL is required",
@@ -446,12 +449,13 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
"statusCode": resp.StatusCode, "statusCode": resp.StatusCode,
"status": resp.StatusCode, "status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(body), "body": string(body),
"headers": respHeaders, "headers": respHeaders,
}) })
} }
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
if jar, ok := r.cookieJar.(*simpleCookieJar); ok { if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
jar.mu.Lock() jar.mu.Lock()
jar.cookies = make(map[string][]*http.Cookie) jar.cookies = make(map[string][]*http.Cookie)
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(0.0) return r.vm.ToValue(0.0)
} }
@@ -22,7 +22,7 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
return r.vm.ToValue(similarity) return r.vm.ToValue(similarity)
} }
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -43,7 +43,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
return r.vm.ToValue(diff <= tolerance) return r.vm.ToValue(diff <= tolerance)
} }
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
+8 -15
View File
@@ -12,11 +12,7 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
// These polyfills make porting browser/Node.js libraries easier func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
// without compromising sandbox security.
// Returns a Promise-like object with json(), text() methods.
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.createFetchError("URL is required") return r.createFetchError("URL is required")
} }
@@ -38,7 +34,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
method = strings.ToUpper(m) method = strings.ToUpper(m)
} }
// Body - support string, object (auto-stringify), or nil
if bodyArg, ok := opts["body"]; ok && bodyArg != nil { if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
switch v := bodyArg.(type) { switch v := bodyArg.(type) {
case string: case string:
@@ -110,7 +105,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
responseObj.Set("status", resp.StatusCode) responseObj.Set("status", resp.StatusCode)
responseObj.Set("statusText", http.StatusText(resp.StatusCode)) responseObj.Set("statusText", http.StatusText(resp.StatusCode))
responseObj.Set("headers", respHeaders) responseObj.Set("headers", respHeaders)
responseObj.Set("url", urlStr) responseObj.Set("url", resp.Request.URL.String())
bodyString := string(body) bodyString := string(body)
@@ -138,7 +133,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return responseObj return responseObj
} }
func (r *ExtensionRuntime) createFetchError(message string) goja.Value { func (r *extensionRuntime) createFetchError(message string) goja.Value {
errorObj := r.vm.NewObject() errorObj := r.vm.NewObject()
errorObj.Set("ok", false) errorObj.Set("ok", false)
errorObj.Set("status", 0) errorObj.Set("status", 0)
@@ -153,7 +148,7 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
return errorObj return errorObj
} }
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -169,7 +164,7 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded)) return r.vm.ToValue(string(decoded))
} }
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -177,7 +172,7 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
} }
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) { func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object { vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
encoder := call.This encoder := call.This
encoder.Set("encoding", "utf-8") encoder.Set("encoding", "utf-8")
@@ -197,7 +192,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
}) })
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value { encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
// Simplified implementation
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0}) return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
} }
@@ -258,7 +252,7 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
}) })
} }
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) { func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object { vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
urlObj := call.This urlObj := call.This
@@ -422,8 +416,7 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
}) })
} }
// JSON is already built-in to Goja; this ensures a fallback exists. func (r *extensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
jsonScript := ` jsonScript := `
if (typeof JSON === 'undefined') { if (typeof JSON === 'undefined') {
var JSON = { var JSON = {
+23 -23
View File
@@ -21,7 +21,7 @@ const (
storageFlushRetryDelay = 2 * time.Second storageFlushRetryDelay = 2 * time.Second
) )
func (r *ExtensionRuntime) getStoragePath() string { func (r *extensionRuntime) getStoragePath() string {
return filepath.Join(r.dataDir, "storage.json") return filepath.Join(r.dataDir, "storage.json")
} }
@@ -36,7 +36,7 @@ func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
return dst return dst
} }
func (r *ExtensionRuntime) ensureStorageLoaded() error { func (r *extensionRuntime) ensureStorageLoaded() error {
r.storageMu.RLock() r.storageMu.RLock()
if r.storageLoaded { if r.storageLoaded {
r.storageMu.RUnlock() r.storageMu.RUnlock()
@@ -74,7 +74,7 @@ func (r *ExtensionRuntime) ensureStorageLoaded() error {
return nil return nil
} }
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) { func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
if err := r.ensureStorageLoaded(); err != nil { if err := r.ensureStorageLoaded(); err != nil {
return nil, err return nil, err
} }
@@ -84,7 +84,7 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
return cloneInterfaceMap(r.storageCache), nil return cloneInterfaceMap(r.storageCache), nil
} }
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) { func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
if r.storageClosed { if r.storageClosed {
return return
} }
@@ -94,7 +94,7 @@ func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync) r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
} }
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error { func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
data, err := json.Marshal(storage) data, err := json.Marshal(storage)
if err != nil { if err != nil {
return err return err
@@ -106,13 +106,13 @@ func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}
return os.WriteFile(r.getStoragePath(), data, 0600) return os.WriteFile(r.getStoragePath(), data, 0600)
} }
func (r *ExtensionRuntime) flushStorageDirtyAsync() { func (r *extensionRuntime) flushStorageDirtyAsync() {
if err := r.flushStorageDirty(); err != nil { if err := r.flushStorageDirty(); err != nil {
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err) GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
} }
} }
func (r *ExtensionRuntime) flushStorageDirty() error { func (r *extensionRuntime) flushStorageDirty() error {
r.storageMu.Lock() r.storageMu.Lock()
if r.storageClosed { if r.storageClosed {
r.storageTimer = nil r.storageTimer = nil
@@ -140,7 +140,7 @@ func (r *ExtensionRuntime) flushStorageDirty() error {
return nil return nil
} }
func (r *ExtensionRuntime) flushStorageNow() error { func (r *extensionRuntime) flushStorageNow() error {
r.storageMu.Lock() r.storageMu.Lock()
if r.storageTimer != nil { if r.storageTimer != nil {
r.storageTimer.Stop() r.storageTimer.Stop()
@@ -157,7 +157,7 @@ func (r *ExtensionRuntime) flushStorageNow() error {
return r.persistStorageSnapshot(snapshot) return r.persistStorageSnapshot(snapshot)
} }
func (r *ExtensionRuntime) closeStorageFlusher() { func (r *extensionRuntime) closeStorageFlusher() {
r.storageMu.Lock() r.storageMu.Lock()
r.storageClosed = true r.storageClosed = true
r.storageDirty = false r.storageDirty = false
@@ -168,7 +168,7 @@ func (r *ExtensionRuntime) closeStorageFlusher() {
r.storageMu.Unlock() r.storageMu.Unlock()
} }
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
} }
@@ -193,7 +193,7 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value) return r.vm.ToValue(value)
} }
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -225,7 +225,7 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -254,15 +254,15 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
func (r *ExtensionRuntime) getCredentialsPath() string { func (r *extensionRuntime) getCredentialsPath() string {
return filepath.Join(r.dataDir, ".credentials.enc") return filepath.Join(r.dataDir, ".credentials.enc")
} }
func (r *ExtensionRuntime) getSaltPath() string { func (r *extensionRuntime) getSaltPath() string {
return filepath.Join(r.dataDir, ".cred_salt") return filepath.Join(r.dataDir, ".cred_salt")
} }
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) { func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath() saltPath := r.getSaltPath()
salt, err := os.ReadFile(saltPath) salt, err := os.ReadFile(saltPath)
@@ -282,7 +282,7 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
return salt, nil return salt, nil
} }
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) { func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
salt, err := r.getOrCreateSalt() salt, err := r.getOrCreateSalt()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -293,7 +293,7 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
return hash[:], nil return hash[:], nil
} }
func (r *ExtensionRuntime) ensureCredentialsLoaded() error { func (r *extensionRuntime) ensureCredentialsLoaded() error {
r.credentialsMu.RLock() r.credentialsMu.RLock()
if r.credentialsLoaded { if r.credentialsLoaded {
r.credentialsMu.RUnlock() r.credentialsMu.RUnlock()
@@ -340,7 +340,7 @@ func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
return nil return nil
} }
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { func (r *extensionRuntime) loadCredentials() (map[string]interface{}, error) {
if err := r.ensureCredentialsLoaded(); err != nil { if err := r.ensureCredentialsLoaded(); err != nil {
return nil, err return nil, err
} }
@@ -350,7 +350,7 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
return cloneInterfaceMap(r.credentialsCache), nil return cloneInterfaceMap(r.credentialsCache), nil
} }
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
data, err := json.Marshal(creds) data, err := json.Marshal(creds)
if err != nil { if err != nil {
return err return err
@@ -377,7 +377,7 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
return nil return nil
} }
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -414,7 +414,7 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
} }
@@ -439,7 +439,7 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(value) return r.vm.ToValue(value)
} }
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -464,7 +464,7 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
+7 -7
View File
@@ -11,7 +11,7 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) { func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value interface{}) {
t.Helper() t.Helper()
result := runtime.storageSet(goja.FunctionCall{ result := runtime.storageSet(goja.FunctionCall{
Arguments: []goja.Value{ Arguments: []goja.Value{
@@ -39,7 +39,7 @@ func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
} }
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) { func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
ext := &LoadedExtension{ ext := &loadedExtension{
ID: "storage-test", ID: "storage-test",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "storage-test", Name: "storage-test",
@@ -47,7 +47,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
DataDir: t.TempDir(), DataDir: t.TempDir(),
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
runtime.storageFlushDelay = 25 * time.Millisecond runtime.storageFlushDelay = 25 * time.Millisecond
runtime.RegisterAPIs(goja.New()) runtime.RegisterAPIs(goja.New())
@@ -86,7 +86,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
} }
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) { func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
ext := &LoadedExtension{ ext := &loadedExtension{
ID: "unload-storage-test", ID: "unload-storage-test",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "unload-storage-test", Name: "unload-storage-test",
@@ -95,13 +95,13 @@ func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
VM: goja.New(), VM: goja.New(),
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
runtime.storageFlushDelay = time.Hour runtime.storageFlushDelay = time.Hour
runtime.RegisterAPIs(ext.VM) runtime.RegisterAPIs(ext.VM)
ext.runtime = runtime ext.runtime = runtime
manager := &ExtensionManager{ manager := &extensionManager{
extensions: map[string]*LoadedExtension{ extensions: map[string]*loadedExtension{
ext.ID: ext, ext.ID: ext,
}, },
} }
+20 -20
View File
@@ -16,7 +16,7 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -24,7 +24,7 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
} }
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -36,7 +36,7 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded)) return r.vm.ToValue(string(decoded))
} }
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -45,7 +45,7 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:])) return r.vm.ToValue(hex.EncodeToString(hash[:]))
} }
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -54,7 +54,7 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(hash[:])) return r.vm.ToValue(hex.EncodeToString(hash[:]))
} }
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -66,7 +66,7 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil))) return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
} }
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -78,7 +78,7 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil))) return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
} }
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue([]byte{}) return r.vm.ToValue([]byte{})
} }
@@ -130,7 +130,7 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(jsArray) return r.vm.ToValue(jsArray)
} }
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return goja.Undefined() return goja.Undefined()
} }
@@ -145,7 +145,7 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result) return r.vm.ToValue(result)
} }
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -160,7 +160,7 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(data)) return r.vm.ToValue(string(data))
} }
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -187,7 +187,7 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -222,7 +222,7 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
}) })
} }
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
length := 32 length := 32
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok { if l, ok := call.Arguments[0].Export().(float64); ok {
@@ -245,35 +245,35 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
}) })
} }
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent()) return r.vm.ToValue(getRandomUserAgent())
} }
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments) msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg) GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
return goja.Undefined() return goja.Undefined()
} }
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments) msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg) GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
return goja.Undefined() return goja.Undefined()
} }
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments) msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg) GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
return goja.Undefined() return goja.Undefined()
} }
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) logError(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments) msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg) GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
return goja.Undefined() return goja.Undefined()
} }
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string { func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
parts := make([]string, len(args)) parts := make([]string, len(args))
for i, arg := range args { for i, arg := range args {
parts[i] = fmt.Sprintf("%v", arg.Export()) parts[i] = fmt.Sprintf("%v", arg.Export())
@@ -281,7 +281,7 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
return strings.Join(parts, " ") return strings.Join(parts, " ")
} }
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value { func (r *extensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue("") return r.vm.ToValue("")
} }
@@ -289,7 +289,7 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
return r.vm.ToValue(sanitizeFilename(input)) return r.vm.ToValue(sanitizeFilename(input))
} }
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) { func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
gobackendObj := vm.Get("gobackend") gobackendObj := vm.Get("gobackend")
if gobackendObj == nil || goja.IsUndefined(gobackendObj) { if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
gobackendObj = vm.NewObject() gobackendObj = vm.NewObject()
+50 -65
View File
@@ -21,7 +21,7 @@ const (
CategoryIntegration = "integration" CategoryIntegration = "integration"
) )
type StoreExtension struct { type storeExtension struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
@@ -41,7 +41,7 @@ type StoreExtension struct {
MinAppVersionAlt string `json:"minAppVersion,omitempty"` MinAppVersionAlt string `json:"minAppVersion,omitempty"`
} }
func (e *StoreExtension) getDisplayName() string { func (e *storeExtension) getDisplayName() string {
if e.DisplayName != "" { if e.DisplayName != "" {
return e.DisplayName return e.DisplayName
} }
@@ -51,34 +51,34 @@ func (e *StoreExtension) getDisplayName() string {
return e.Name return e.Name
} }
func (e *StoreExtension) getDownloadURL() string { func (e *storeExtension) getDownloadURL() string {
if e.DownloadURL != "" { if e.DownloadURL != "" {
return e.DownloadURL return e.DownloadURL
} }
return e.DownloadURLAlt return e.DownloadURLAlt
} }
func (e *StoreExtension) getIconURL() string { func (e *storeExtension) getIconURL() string {
if e.IconURL != "" { if e.IconURL != "" {
return e.IconURL return e.IconURL
} }
return e.IconURLAlt return e.IconURLAlt
} }
func (e *StoreExtension) getMinAppVersion() string { func (e *storeExtension) getMinAppVersion() string {
if e.MinAppVersion != "" { if e.MinAppVersion != "" {
return e.MinAppVersion return e.MinAppVersion
} }
return e.MinAppVersionAlt return e.MinAppVersionAlt
} }
type StoreRegistry struct { type storeRegistry struct {
Version int `json:"version"` Version int `json:"version"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
Extensions []StoreExtension `json:"extensions"` Extensions []storeExtension `json:"extensions"`
} }
type StoreExtensionResponse struct { type storeExtensionResponse struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
@@ -97,8 +97,8 @@ type StoreExtensionResponse struct {
HasUpdate bool `json:"has_update"` HasUpdate bool `json:"has_update"`
} }
func (e *StoreExtension) ToResponse() *StoreExtensionResponse { func (e *storeExtension) toResponse() storeExtensionResponse {
return &StoreExtensionResponse{ resp := storeExtensionResponse{
ID: e.ID, ID: e.ID,
Name: e.Name, Name: e.Name,
DisplayName: e.getDisplayName(), DisplayName: e.getDisplayName(),
@@ -108,25 +108,30 @@ func (e *StoreExtension) ToResponse() *StoreExtensionResponse {
DownloadURL: e.getDownloadURL(), DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(), IconURL: e.getIconURL(),
Category: e.Category, Category: e.Category,
Tags: e.Tags,
Downloads: e.Downloads, Downloads: e.Downloads,
UpdatedAt: e.UpdatedAt, UpdatedAt: e.UpdatedAt,
MinAppVersion: e.getMinAppVersion(), MinAppVersion: e.getMinAppVersion(),
} }
if len(e.Tags) > 0 {
resp.Tags = append([]string(nil), e.Tags...)
}
return resp
} }
type ExtensionStore struct { type extensionStore struct {
registryURL string registryURL string
cacheDir string cacheDir string
cache *StoreRegistry cache *storeRegistry
cacheMu sync.RWMutex cacheMu sync.RWMutex
cacheTime time.Time cacheTime time.Time
cacheTTL time.Duration cacheTTL time.Duration
} }
var ( var (
extensionStore *ExtensionStore globalExtensionStore *extensionStore
extensionStoreMu sync.Mutex extensionStoreMu sync.Mutex
) )
const ( const (
@@ -134,24 +139,22 @@ const (
cacheFileName = "store_cache.json" cacheFileName = "store_cache.json"
) )
func InitExtensionStore(cacheDir string) *ExtensionStore { func initExtensionStore(cacheDir string) *extensionStore {
extensionStoreMu.Lock() extensionStoreMu.Lock()
defer extensionStoreMu.Unlock() defer extensionStoreMu.Unlock()
if extensionStore == nil { if globalExtensionStore == nil {
extensionStore = &ExtensionStore{ globalExtensionStore = &extensionStore{
registryURL: "", // No default - user must provide a registry URL registryURL: "",
cacheDir: cacheDir, cacheDir: cacheDir,
cacheTTL: cacheTTL, cacheTTL: cacheTTL,
} }
extensionStore.loadDiskCache() globalExtensionStore.loadDiskCache()
} }
return extensionStore return globalExtensionStore
} }
// SetRegistryURL updates the registry URL and clears the in-memory cache func (s *extensionStore) setRegistryURL(registryURL string) {
// so the next fetch will use the new URL. Disk cache is also cleared.
func (s *ExtensionStore) SetRegistryURL(registryURL string) {
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
@@ -163,7 +166,6 @@ func (s *ExtensionStore) SetRegistryURL(registryURL string) {
s.cache = nil s.cache = nil
s.cacheTime = time.Time{} s.cacheTime = time.Time{}
// Clear disk cache since it's from a different registry
if s.cacheDir != "" { if s.cacheDir != "" {
cachePath := filepath.Join(s.cacheDir, cacheFileName) cachePath := filepath.Join(s.cacheDir, cacheFileName)
os.Remove(cachePath) os.Remove(cachePath)
@@ -172,20 +174,19 @@ func (s *ExtensionStore) SetRegistryURL(registryURL string) {
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL) LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
} }
// GetRegistryURL returns the currently configured registry URL. func (s *extensionStore) getRegistryURL() string {
func (s *ExtensionStore) GetRegistryURL() string {
s.cacheMu.RLock() s.cacheMu.RLock()
defer s.cacheMu.RUnlock() defer s.cacheMu.RUnlock()
return s.registryURL return s.registryURL
} }
func GetExtensionStore() *ExtensionStore { func getExtensionStore() *extensionStore {
extensionStoreMu.Lock() extensionStoreMu.Lock()
defer extensionStoreMu.Unlock() defer extensionStoreMu.Unlock()
return extensionStore return globalExtensionStore
} }
func (s *ExtensionStore) loadDiskCache() { func (s *extensionStore) loadDiskCache() {
if s.cacheDir == "" { if s.cacheDir == "" {
return return
} }
@@ -197,7 +198,7 @@ func (s *ExtensionStore) loadDiskCache() {
} }
var cacheData struct { var cacheData struct {
Registry StoreRegistry `json:"registry"` Registry storeRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"` CacheTime int64 `json:"cache_time"`
} }
@@ -210,13 +211,13 @@ func (s *ExtensionStore) loadDiskCache() {
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions)) LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
} }
func (s *ExtensionStore) saveDiskCache() { func (s *extensionStore) saveDiskCache() {
if s.cacheDir == "" || s.cache == nil { if s.cacheDir == "" || s.cache == nil {
return return
} }
cacheData := struct { cacheData := struct {
Registry StoreRegistry `json:"registry"` Registry storeRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"` CacheTime int64 `json:"cache_time"`
}{ }{
Registry: *s.cache, Registry: *s.cache,
@@ -232,7 +233,7 @@ func (s *ExtensionStore) saveDiskCache() {
os.WriteFile(cachePath, data, 0644) os.WriteFile(cachePath, data, 0644)
} }
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) { func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
@@ -275,7 +276,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return nil, fmt.Errorf("failed to read registry: %w", err) return nil, fmt.Errorf("failed to read registry: %w", err)
} }
var registry StoreRegistry var registry storeRegistry
if err := json.Unmarshal(body, &registry); err != nil { if err := json.Unmarshal(body, &registry); err != nil {
return nil, fmt.Errorf("failed to parse registry: %w", err) return nil, fmt.Errorf("failed to parse registry: %w", err)
} }
@@ -288,13 +289,13 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return &registry, nil return &registry, nil
} }
func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExtensionResponse, error) { func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
registry, err := s.FetchRegistry(forceRefresh) registry, err := s.fetchRegistry(forceRefresh)
if err != nil { if err != nil {
return nil, err return nil, err
} }
manager := GetExtensionManager() manager := getExtensionManager()
installed := make(map[string]string) // id -> version installed := make(map[string]string) // id -> version
if manager != nil { if manager != nil {
@@ -305,10 +306,10 @@ func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExt
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed)) LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
result := make([]*StoreExtensionResponse, 0, len(registry.Extensions)) result := make([]storeExtensionResponse, 0, len(registry.Extensions))
for i := range registry.Extensions { for i := range registry.Extensions {
ext := &registry.Extensions[i] ext := &registry.Extensions[i]
resp := ext.ToResponse() resp := ext.toResponse()
if installedVersion, ok := installed[ext.ID]; ok { if installedVersion, ok := installed[ext.ID]; ok {
resp.IsInstalled = true resp.IsInstalled = true
resp.InstalledVersion = installedVersion resp.InstalledVersion = installedVersion
@@ -322,17 +323,13 @@ func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExt
return result, nil return result, nil
} }
func (s *ExtensionStore) GetExtensionsWithStatus() ([]*StoreExtensionResponse, error) { func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
return s.getExtensionsWithStatus(false) registry, err := s.fetchRegistry(false)
}
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false)
if err != nil { if err != nil {
return err return err
} }
var ext *StoreExtension var ext *storeExtension
for _, e := range registry.Extensions { for _, e := range registry.Extensions {
if e.ID == extensionID { if e.ID == extensionID {
ext = &e ext = &e
@@ -377,32 +374,22 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return nil return nil
} }
// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL. func resolveRegistryURL(input string) (string, error) {
//
// Accepted formats:
// - https://raw.githubusercontent.com/owner/repo/<branch>/registry.json → returned as-is
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
// the GitHub API to discover the default branch, then converted to the raw URL
// - Any other HTTPS URL → returned as-is (assumed to be a direct link)
func ResolveRegistryURL(input string) (string, error) {
input = strings.TrimSpace(input) input = strings.TrimSpace(input)
if input == "" { if input == "" {
return "", fmt.Errorf("registry URL is empty") return "", fmt.Errorf("registry URL is empty")
} }
// Already a fully-qualified raw URL keep it.
if strings.Contains(input, "raw.githubusercontent.com") { if strings.Contains(input, "raw.githubusercontent.com") {
return input, nil return input, nil
} }
const ghPrefix = "https://github.com/" const ghPrefix = "https://github.com/"
if !strings.HasPrefix(input, ghPrefix) { if !strings.HasPrefix(input, ghPrefix) {
// Also accept http:// and upgrade silently.
const ghPrefixHTTP = "http://github.com/" const ghPrefixHTTP = "http://github.com/"
if strings.HasPrefix(input, ghPrefixHTTP) { if strings.HasPrefix(input, ghPrefixHTTP) {
input = "https://github.com/" + input[len(ghPrefixHTTP):] input = "https://github.com/" + input[len(ghPrefixHTTP):]
} else { } else {
// Not a GitHub URL return as-is.
return input, nil return input, nil
} }
} }
@@ -422,8 +409,6 @@ func ResolveRegistryURL(input string) (string, error) {
return resolved, nil return resolved, nil
} }
// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's
// default branch. Falls back to "main" on any error.
func resolveGitHubDefaultBranch(owner, repo string) string { func resolveGitHubDefaultBranch(owner, repo string) string {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo) apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
client := NewHTTPClientWithTimeout(10 * time.Second) client := NewHTTPClientWithTimeout(10 * time.Second)
@@ -465,7 +450,7 @@ func requireHTTPSURL(rawURL string, context string) error {
return nil return nil
} }
func (s *ExtensionStore) GetCategories() []string { func (s *extensionStore) getCategories() []string {
return []string{ return []string{
CategoryMetadata, CategoryMetadata,
CategoryDownload, CategoryDownload,
@@ -475,8 +460,8 @@ func (s *ExtensionStore) GetCategories() []string {
} }
} }
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*StoreExtensionResponse, error) { func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus() extensions, err := s.getExtensionsWithStatus(false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -485,7 +470,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*Sto
return extensions, nil return extensions, nil
} }
result := make([]*StoreExtensionResponse, 0, len(extensions)) result := make([]storeExtensionResponse, 0, len(extensions))
queryLower := toLower(query) queryLower := toLower(query)
for _, ext := range extensions { for _, ext := range extensions {
@@ -517,7 +502,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*Sto
return result, nil return result, nil
} }
func (s *ExtensionStore) ClearCache() { func (s *extensionStore) clearCache() {
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
+10 -10
View File
@@ -99,7 +99,7 @@ func TestIsDomainAllowed(t *testing.T) {
func TestExtensionRuntime_NetworkSandbox(t *testing.T) { func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
// Create a mock extension with limited network permissions // Create a mock extension with limited network permissions
ext := &LoadedExtension{ ext := &loadedExtension{
ID: "test-ext", ID: "test-ext",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "test-ext", Name: "test-ext",
@@ -110,7 +110,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
DataDir: t.TempDir(), DataDir: t.TempDir(),
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil { if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err) t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
@@ -132,7 +132,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
func TestExtensionRuntime_FileSandbox(t *testing.T) { func TestExtensionRuntime_FileSandbox(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
ext := &LoadedExtension{ ext := &loadedExtension{
ID: "test-ext", ID: "test-ext",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "test-ext", Name: "test-ext",
@@ -143,7 +143,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
DataDir: tempDir, DataDir: tempDir,
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
validPath, err := runtime.validatePath("test.txt") validPath, err := runtime.validatePath("test.txt")
if err != nil { if err != nil {
@@ -177,7 +177,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
t.Error("Expected absolute path to be blocked") t.Error("Expected absolute path to be blocked")
} }
extNoFile := &LoadedExtension{ extNoFile := &loadedExtension{
ID: "test-ext-no-file", ID: "test-ext-no-file",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "test-ext-no-file", Name: "test-ext-no-file",
@@ -187,7 +187,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
}, },
DataDir: tempDir, DataDir: tempDir,
} }
runtimeNoFile := NewExtensionRuntime(extNoFile) runtimeNoFile := newExtensionRuntime(extNoFile)
_, err = runtimeNoFile.validatePath("test.txt") _, err = runtimeNoFile.validatePath("test.txt")
if err == nil { if err == nil {
t.Error("Expected file access to be denied without file permission") t.Error("Expected file access to be denied without file permission")
@@ -195,7 +195,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
} }
func TestExtensionRuntime_UtilityFunctions(t *testing.T) { func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
ext := &LoadedExtension{ ext := &loadedExtension{
ID: "test-ext", ID: "test-ext",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "test-ext", Name: "test-ext",
@@ -203,7 +203,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
DataDir: t.TempDir(), DataDir: t.TempDir(),
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
vm := goja.New() vm := goja.New()
runtime.RegisterAPIs(vm) runtime.RegisterAPIs(vm)
@@ -243,7 +243,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
func TestExtensionRuntime_SSRFProtection(t *testing.T) { func TestExtensionRuntime_SSRFProtection(t *testing.T) {
// Create extension with limited network permissions // Create extension with limited network permissions
ext := &LoadedExtension{ ext := &loadedExtension{
ID: "test-ext", ID: "test-ext",
Manifest: &ExtensionManifest{ Manifest: &ExtensionManifest{
Name: "test-ext", Name: "test-ext",
@@ -254,7 +254,7 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
DataDir: t.TempDir(), DataDir: t.TempDir(),
} }
runtime := NewExtensionRuntime(ext) runtime := newExtensionRuntime(ext)
privateIPs := []string{ privateIPs := []string{
"http://localhost/admin", "http://localhost/admin",
+17 -4
View File
@@ -20,6 +20,10 @@ func (e *JSExecutionError) Error() string {
} }
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) { func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
if vm == nil {
return nil, fmt.Errorf("extension runtime unavailable")
}
if timeout <= 0 { if timeout <= 0 {
timeout = DefaultJSTimeout timeout = DefaultJSTimeout
} }
@@ -49,7 +53,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())) 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)}
} }
} }
@@ -69,6 +73,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
vm.Interrupt("execution timeout") vm.Interrupt("execution timeout")
// MUST wait for the goroutine to finish before returning.
// The Goja VM is NOT thread-safe — if we return while the goroutine
// is still executing JS (e.g. blocked on an HTTP call), the next
// caller will access the VM concurrently and crash with a nil
// pointer dereference.
select { select {
case res := <-resultCh: case res := <-resultCh:
if res.err != nil { if res.err != nil {
@@ -78,7 +87,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
Message: "execution timeout exceeded", Message: "execution timeout exceeded",
IsTimeout: true, IsTimeout: true,
} }
case <-time.After(1 * time.Second): case <-time.After(60 * time.Second):
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
// Log a warning — the VM should NOT be reused after this.
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
return nil, &JSExecutionError{ return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)", Message: "execution timeout exceeded (force)",
IsTimeout: true, IsTimeout: true,
@@ -92,8 +104,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) { func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
result, err := RunWithTimeout(vm, script, timeout) result, err := RunWithTimeout(vm, script, timeout)
// Clear any interrupt state so VM can be reused if vm != nil {
vm.ClearInterrupt() vm.ClearInterrupt()
}
return result, err return result, err
} }
+15 -15
View File
@@ -2,28 +2,28 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0 go 1.25.0
toolchain go1.25.7 toolchain go1.25.8
require ( require (
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
github.com/go-flac/flacpicture/v2 v2.0.2 github.com/go-flac/flacpicture/v2 v2.0.2
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-20260211191516-dcd2a3258864 golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60
golang.org/x/net v0.50.0 golang.org/x/net v0.52.0
golang.org/x/text v0.34.0 golang.org/x/text v0.35.0
) )
require ( require (
github.com/andybalholm/brotli v1.0.6 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/compress v1.18.5 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/tools v0.42.0 // indirect golang.org/x/tools v0.43.0 // indirect
) )
+30 -28
View File
@@ -1,49 +1,51 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw= github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo= github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ= github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g= github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0= github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs= github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
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=
-7
View File
@@ -66,9 +66,6 @@ var sharedTransport = &http.Transport{
DisableCompression: true, DisableCompression: true,
} }
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
// Isolated from download traffic so that download failures cannot poison
// the connection pool used by metadata enrichment.
var metadataTransport = &http.Transport{ var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
@@ -104,8 +101,6 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
} }
} }
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
// Use this for API calls that should not be affected by download traffic.
func NewMetadataHTTPClient(timeout time.Duration) *http.Client { func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{ return &http.Client{
Transport: newCompatibilityTransport(metadataTransport), Transport: newCompatibilityTransport(metadataTransport),
@@ -229,7 +224,6 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
return reqCopy, nil return reqCopy, nil
} }
// Also checks for ISP blocking on errors
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req) resp, err := client.Do(req)
@@ -239,7 +233,6 @@ func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Respo
return resp, err return resp, err
} }
// RetryConfig holds configuration for retry logic
type RetryConfig struct { type RetryConfig struct {
MaxRetries int MaxRetries int
InitialDelay time.Duration InitialDelay time.Duration
-7
View File
@@ -6,17 +6,10 @@ import (
"net/http" "net/http"
) )
// iOS version: uTLS is not supported on iOS due to cgo DNS resolver issues
// Fall back to standard HTTP client
// GetCloudflareBypassClient returns the standard HTTP client on iOS
// uTLS is not available on iOS due to cgo DNS resolver compatibility issues
func GetCloudflareBypassClient() *http.Client { func GetCloudflareBypassClient() *http.Client {
return sharedClient return sharedClient
} }
// DoRequestWithCloudflareBypass on iOS just uses the standard client
// uTLS Chrome fingerprint bypass is not available on iOS
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := sharedClient.Do(req) resp, err := sharedClient.Do(req)
-8
View File
@@ -16,8 +16,6 @@ import (
"golang.org/x/net/http2" "golang.org/x/net/http2"
) )
// uTLS transport that mimics Chrome's TLS fingerprint to bypass Cloudflare
// Uses HTTP/2 for optimal performance as uTLS works best with HTTP/2
type utlsTransport struct { type utlsTransport struct {
dialer *net.Dialer dialer *net.Dialer
mu sync.Mutex mu sync.Mutex
@@ -98,15 +96,10 @@ var cloudflareBypassClient = &http.Client{
Timeout: DefaultTimeout, Timeout: DefaultTimeout,
} }
// GetCloudflareBypassClient returns an HTTP client that mimics Chrome's TLS fingerprint
// Use this when requests are blocked by Cloudflare (common when using VPN)
func GetCloudflareBypassClient() *http.Client { func GetCloudflareBypassClient() *http.Client {
return cloudflareBypassClient return cloudflareBypassClient
} }
// DoRequestWithCloudflareBypass attempts request with standard client first,
// then retries with uTLS Chrome fingerprint if Cloudflare blocks it.
// This is useful when using VPN as Cloudflare detects Go's default TLS fingerprint.
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
@@ -142,7 +135,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
} }
} }
// Not Cloudflare, return original response (recreate body)
return &http.Response{ return &http.Response{
Status: resp.Status, Status: resp.Status,
StatusCode: resp.StatusCode, StatusCode: resp.StatusCode,
-4
View File
@@ -10,8 +10,6 @@ import (
"time" "time"
) )
// IDHSClient is a client for I Don't Have Spotify API
// Used as fallback when SongLink fails or is rate limited
type IDHSClient struct { type IDHSClient struct {
client *http.Client client *http.Client
} }
@@ -55,7 +53,6 @@ func NewIDHSClient() *IDHSClient {
return globalIDHSClient return globalIDHSClient
} }
// Search converts a music link to links on other platforms
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) { func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
idhsRateLimiter.WaitForSlot() idhsRateLimiter.WaitForSlot()
@@ -109,7 +106,6 @@ func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse
return &result, nil return &result, nil
} }
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) { func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
+128 -40
View File
@@ -13,25 +13,31 @@ import (
) )
type LibraryScanResult struct { type LibraryScanResult struct {
ID string `json:"id"` ID string `json:"id"`
TrackName string `json:"trackName"` TrackName string `json:"trackName"`
ArtistName string `json:"artistName"` ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"` AlbumName string `json:"albumName"`
AlbumArtist string `json:"albumArtist,omitempty"` AlbumArtist string `json:"albumArtist,omitempty"`
FilePath string `json:"filePath"` FilePath string `json:"filePath"`
CoverPath string `json:"coverPath,omitempty"` CoverPath string `json:"coverPath,omitempty"`
ScannedAt string `json:"scannedAt"` ScannedAt string `json:"scannedAt"`
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
TrackNumber int `json:"trackNumber,omitempty"` TrackNumber int `json:"trackNumber,omitempty"`
DiscNumber int `json:"discNumber,omitempty"` TotalTracks int `json:"totalTracks,omitempty"`
Duration int `json:"duration,omitempty"` DiscNumber int `json:"discNumber,omitempty"`
ReleaseDate string `json:"releaseDate,omitempty"` TotalDiscs int `json:"totalDiscs,omitempty"`
BitDepth int `json:"bitDepth,omitempty"` Duration int `json:"duration,omitempty"`
SampleRate int `json:"sampleRate,omitempty"` ReleaseDate string `json:"releaseDate,omitempty"`
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis) BitDepth int `json:"bitDepth,omitempty"`
Genre string `json:"genre,omitempty"` SampleRate int `json:"sampleRate,omitempty"`
Format string `json:"format,omitempty"` Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
Genre string `json:"genre,omitempty"`
Composer string `json:"composer,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Format string `json:"format,omitempty"`
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
} }
type LibraryScanProgress struct { type LibraryScanProgress struct {
@@ -65,6 +71,9 @@ var supportedAudioFormats = map[string]bool{
".mp3": true, ".mp3": true,
".opus": true, ".opus": true,
".ogg": true, ".ogg": true,
".ape": true,
".wv": true,
".mpc": true,
".cue": true, ".cue": true,
} }
@@ -169,11 +178,9 @@ func ScanLibraryFolder(folderPath string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339) scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0 errorCount := 0
// Track audio files referenced by .cue sheets to avoid duplicates
cueReferencedAudioFiles := make(map[string]bool) cueReferencedAudioFiles := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo) parsedCueFiles := make(map[string]scannedCueFileInfo)
// First pass: scan .cue files to collect referenced audio paths
for _, fileInfo := range audioFileInfos { for _, fileInfo := range audioFileInfos {
filePath := fileInfo.path filePath := fileInfo.path
ext := strings.ToLower(filepath.Ext(filePath)) ext := strings.ToLower(filepath.Ext(filePath))
@@ -208,7 +215,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
ext := strings.ToLower(filepath.Ext(filePath)) ext := strings.ToLower(filepath.Ext(filePath))
// Handle .cue files: produce multiple track results
if ext == ".cue" { if ext == ".cue" {
var cueResults []LibraryScanResult var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[filePath] cueInfo, ok := parsedCueFiles[filePath]
@@ -219,6 +225,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
cueInfo.audioPath, cueInfo.audioPath,
"", "",
fileInfo.modTime, fileInfo.modTime,
"",
scanTime, scanTime,
) )
} else { } else {
@@ -269,10 +276,14 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
} }
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) { func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime) return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
} }
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) { func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime)
}
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
ext := resolveLibraryAudioExt(filePath, displayNameHint) ext := resolveLibraryAudioExt(filePath, displayNameHint)
result := &LibraryScanResult{ result := &LibraryScanResult{
@@ -292,7 +303,12 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
coverCacheDir := libraryCoverCacheDir coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock() libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" { if coverCacheDir != "" {
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir) coverPath, err := SaveCoverToCacheWithHintAndKey(
filePath,
displayNameHint,
coverCacheDir,
coverCacheKey,
)
if err == nil && coverPath != "" { if err == nil && coverPath != "" {
result.CoverPath = coverPath result.CoverPath = coverPath
} }
@@ -300,13 +316,15 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
switch ext { switch ext {
case ".flac": case ".flac":
return scanFLACFile(filePath, result) return scanFLACFile(filePath, result, displayNameHint)
case ".m4a": case ".m4a":
return scanM4AFile(filePath, result) return scanM4AFile(filePath, result, displayNameHint)
case ".mp3": case ".mp3":
return scanMP3File(filePath, result) return scanMP3File(filePath, result, displayNameHint)
case ".opus", ".ogg": case ".opus", ".ogg":
return scanOggFile(filePath, result, displayNameHint) return scanOggFile(filePath, result, displayNameHint)
case ".ape", ".wv", ".mpc":
return scanAPEFile(filePath, result, displayNameHint)
default: default:
return scanFromFilename(filePath, displayNameHint, result) return scanFromFilename(filePath, displayNameHint, result)
} }
@@ -340,10 +358,10 @@ func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *Libra
} }
} }
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadMetadata(filePath) metadata, err := ReadMetadata(filePath)
if err != nil { if err != nil {
return scanFromFilename(filePath, "", result) return scanFromFilename(filePath, displayNameHint, result)
} }
result.TrackName = metadata.Title result.TrackName = metadata.Title
@@ -352,9 +370,14 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
result.AlbumArtist = metadata.AlbumArtist result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.ReleaseDate = metadata.Date result.ReleaseDate = metadata.Date
result.Genre = metadata.Genre result.Genre = metadata.Genre
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetAudioQuality(filePath) quality, err := GetAudioQuality(filePath)
if err == nil { if err == nil {
@@ -365,26 +388,36 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
} }
} }
applyDefaultLibraryMetadata(filePath, "", result) applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil return result, nil
} }
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadM4ATags(filePath) metadata, err := ReadM4ATags(filePath)
if err == nil && metadata != nil { if err != nil {
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
if metadata != nil {
result.TrackName = metadata.Title result.TrackName = metadata.Title
result.ArtistName = metadata.Artist result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.ReleaseDate = metadata.Date result.ReleaseDate = metadata.Date
if result.ReleaseDate == "" { if result.ReleaseDate == "" {
result.ReleaseDate = metadata.Year result.ReleaseDate = metadata.Year
} }
result.Genre = metadata.Genre result.Genre = metadata.Genre
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
} }
quality, err := GetM4AQuality(filePath) quality, err := GetM4AQuality(filePath)
@@ -393,15 +426,15 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
result.SampleRate = quality.SampleRate result.SampleRate = quality.SampleRate
} }
applyDefaultLibraryMetadata(filePath, "", result) applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil return result, nil
} }
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath) metadata, err := ReadID3Tags(filePath)
if err != nil { if err != nil {
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err) GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, "", result) return scanFromFilename(filePath, displayNameHint, result)
} }
result.TrackName = metadata.Title result.TrackName = metadata.Title
@@ -409,7 +442,9 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
result.AlbumName = metadata.Album result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist result.AlbumArtist = metadata.AlbumArtist
result.TrackNumber = metadata.TrackNumber result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.Genre = metadata.Genre result.Genre = metadata.Genre
if metadata.Date != "" { if metadata.Date != "" {
result.ReleaseDate = metadata.Date result.ReleaseDate = metadata.Date
@@ -417,6 +452,9 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
result.ReleaseDate = metadata.Year result.ReleaseDate = metadata.Year
} }
result.ISRC = metadata.ISRC result.ISRC = metadata.ISRC
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetMP3Quality(filePath) quality, err := GetMP3Quality(filePath)
if err == nil { if err == nil {
@@ -428,7 +466,7 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
} }
} }
applyDefaultLibraryMetadata(filePath, "", result) applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil return result, nil
} }
@@ -446,9 +484,14 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
result.AlbumArtist = metadata.AlbumArtist result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.Genre = metadata.Genre result.Genre = metadata.Genre
result.ReleaseDate = metadata.Date result.ReleaseDate = metadata.Date
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetOggQuality(filePath) quality, err := GetOggQuality(filePath)
if err == nil { if err == nil {
@@ -465,7 +508,44 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
return result, nil return result, nil
} }
func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
tag, err := ReadAPETags(filePath)
if err != nil {
GoLog("[LibraryScan] APE tag read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
metadata := APETagToAudioMetadata(tag)
if metadata == nil {
return scanFromFilename(filePath, displayNameHint, result)
}
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
result.Genre = metadata.Genre
if metadata.Date != "" {
result.ReleaseDate = metadata.Date
} else {
result.ReleaseDate = metadata.Year
}
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) { func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
result.MetadataFromFilename = true
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint) nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource)) filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
@@ -541,8 +621,18 @@ func ReadAudioMetadata(filePath string) (string, error) {
} }
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) { func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, "")
}
func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339) scanTime := time.Now().UTC().Format(time.RFC3339)
result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0) result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
filePath,
displayNameHint,
coverCacheKey,
scanTime,
0,
)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -746,6 +836,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
cueInfo.audioPath, cueInfo.audioPath,
"", "",
f.modTime, f.modTime,
"",
scanTime, scanTime,
) )
} else { } else {
@@ -799,9 +890,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) { func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
existingFiles := make(map[string]int64) existingFiles := make(map[string]int64)
if existingFilesJSON != "" && existingFilesJSON != "{}" { if existingFilesJSON != "" && existingFilesJSON != "{}" {
+25
View File
@@ -0,0 +1,25 @@
package gobackend
import "testing"
func TestScanFromFilenameMarksMetadataFallback(t *testing.T) {
result := &LibraryScanResult{}
scanned, err := scanFromFilename(
"/proc/self/fd/209",
"189.mp3",
result,
)
if err != nil {
t.Fatalf("scanFromFilename returned error: %v", err)
}
if !scanned.MetadataFromFilename {
t.Fatal("expected filename fallback marker to be set")
}
if scanned.TrackName != "189" {
t.Fatalf("unexpected track name: %q", scanned.TrackName)
}
if scanned.ArtistName != "Unknown Artist" {
t.Fatalf("unexpected artist name: %q", scanned.ArtistName)
}
}
+1 -15
View File
@@ -25,7 +25,6 @@ type LogBuffer struct {
const ( const (
defaultLogBufferSize = 500 defaultLogBufferSize = 500
maxLogMessageLength = 500
) )
var ( var (
@@ -52,20 +51,12 @@ func GetLogBuffer() *LogBuffer {
globalLogBuffer = &LogBuffer{ globalLogBuffer = &LogBuffer{
entries: make([]LogEntry, 0, defaultLogBufferSize), entries: make([]LogEntry, 0, defaultLogBufferSize),
maxSize: defaultLogBufferSize, maxSize: defaultLogBufferSize,
loggingEnabled: false, // Default: disabled for performance (user can enable in settings) loggingEnabled: false,
} }
}) })
return globalLogBuffer return globalLogBuffer
} }
func truncateLogMessage(message string) string {
runes := []rune(message)
if len(runes) <= maxLogMessageLength {
return message
}
return string(runes[:maxLogMessageLength]) + "...[truncated]"
}
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) { func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock() lb.mu.Lock()
defer lb.mu.Unlock() defer lb.mu.Unlock()
@@ -87,7 +78,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
} }
message = sanitizeSensitiveLogText(message) message = sanitizeSensitiveLogText(message)
message = truncateLogMessage(message)
entry := LogEntry{ entry := LogEntry{
Timestamp: time.Now().Format("15:04:05.000"), Timestamp: time.Now().Format("15:04:05.000"),
@@ -155,13 +145,10 @@ func LogError(tag, format string, args ...interface{}) {
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...)) GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
} }
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
// It parses the tag from the format string if it starts with [Tag]
func GoLog(format string, args ...interface{}) { func GoLog(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...) message := fmt.Sprintf(format, args...)
message = strings.TrimSuffix(message, "\n") message = strings.TrimSuffix(message, "\n")
// Extract tag from message if present (e.g., "[Tidal] message")
tag := "Go" tag := "Go"
level := "INFO" level := "INFO"
@@ -173,7 +160,6 @@ func GoLog(format string, args ...interface{}) {
} }
} }
// Determine level from message content
msgLower := strings.ToLower(message) msgLower := strings.ToLower(message)
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") { if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
level = "ERROR" level = "ERROR"
+15 -252
View File
@@ -3,7 +3,6 @@ package gobackend
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"math" "math"
"net/http" "net/http"
"net/url" "net/url"
@@ -21,9 +20,7 @@ const (
durationToleranceSec = 10.0 durationToleranceSec = 10.0
) )
// Lyrics provider names (used in settings and cascade ordering)
const ( const (
LyricsProviderSpotifyAPI = "spotify_api"
LyricsProviderLRCLIB = "lrclib" LyricsProviderLRCLIB = "lrclib"
LyricsProviderNetease = "netease" LyricsProviderNetease = "netease"
LyricsProviderMusixmatch = "musixmatch" LyricsProviderMusixmatch = "musixmatch"
@@ -31,11 +28,8 @@ const (
LyricsProviderQQMusic = "qqmusic" LyricsProviderQQMusic = "qqmusic"
) )
// DefaultLyricsProviders is the default cascade order for lyrics fetching.
// LRCLIB first (no proxy dependency), then the others.
var DefaultLyricsProviders = []string{ var DefaultLyricsProviders = []string{
LyricsProviderLRCLIB, LyricsProviderLRCLIB,
LyricsProviderSpotifyAPI,
LyricsProviderMusixmatch, LyricsProviderMusixmatch,
LyricsProviderNetease, LyricsProviderNetease,
LyricsProviderAppleMusic, LyricsProviderAppleMusic,
@@ -47,12 +41,6 @@ var (
lyricsProviders []string // ordered list of enabled providers lyricsProviders []string // ordered list of enabled providers
) )
var (
spotifyLyricsRateLimitMu sync.RWMutex
spotifyLyricsRateLimitedTil time.Time
)
// LyricsFetchOptions controls optional provider-specific enhancements.
type LyricsFetchOptions struct { type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"` IncludeTranslationNetease bool `json:"include_translation_netease"`
IncludeRomanizationNetease bool `json:"include_romanization_netease"` IncludeRomanizationNetease bool `json:"include_romanization_netease"`
@@ -72,8 +60,6 @@ var (
lyricsFetchOptions = defaultLyricsFetchOptions lyricsFetchOptions = defaultLyricsFetchOptions
) )
// SetLyricsProviderOrder sets the ordered list of lyrics providers to try.
// Providers not in the list are disabled. An empty list resets to defaults.
func SetLyricsProviderOrder(providers []string) { func SetLyricsProviderOrder(providers []string) {
lyricsProvidersMu.Lock() lyricsProvidersMu.Lock()
defer lyricsProvidersMu.Unlock() defer lyricsProvidersMu.Unlock()
@@ -84,7 +70,6 @@ func SetLyricsProviderOrder(providers []string) {
} }
validNames := map[string]bool{ validNames := map[string]bool{
LyricsProviderSpotifyAPI: true,
LyricsProviderLRCLIB: true, LyricsProviderLRCLIB: true,
LyricsProviderNetease: true, LyricsProviderNetease: true,
LyricsProviderMusixmatch: true, LyricsProviderMusixmatch: true,
@@ -119,7 +104,6 @@ func GetLyricsProviderOrder() []string {
func GetAvailableLyricsProviders() []map[string]interface{} { func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{ return []map[string]interface{}{
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"}, {"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"}, {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"}, {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
@@ -249,18 +233,6 @@ type LRCLibResponse struct {
SyncedLyrics string `json:"syncedLyrics"` SyncedLyrics string `json:"syncedLyrics"`
} }
type SpotifyLyricsLine struct {
TimeTag string `json:"timeTag"`
Words string `json:"words"`
}
type SpotifyLyricsAPIResponse struct {
Error bool `json:"error"`
Message string `json:"message"`
SyncType string `json:"syncType"`
Lines []SpotifyLyricsLine `json:"lines"`
}
type LyricsLine struct { type LyricsLine struct {
StartTimeMs int64 `json:"startTimeMs"` StartTimeMs int64 `json:"startTimeMs"`
Words string `json:"words"` Words string `json:"words"`
@@ -368,214 +340,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
return c.parseLRCLibResponse(&results[0]), nil return c.parseLRCLibResponse(&results[0]), nil
} }
func parseSpotifyLyricsTimeTagToMs(tag string) int64 {
raw := strings.TrimSpace(tag)
raw = strings.TrimPrefix(raw, "[")
raw = strings.TrimSuffix(raw, "]")
if raw == "" {
return 0
}
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
return ms
}
re := regexp.MustCompile(`^(\d{1,2}):(\d{2})\.(\d{1,3})$`)
matches := re.FindStringSubmatch(raw)
if len(matches) != 4 {
return 0
}
minutes, _ := strconv.ParseInt(matches[1], 10, 64)
seconds, _ := strconv.ParseInt(matches[2], 10, 64)
fraction := matches[3]
fractionInt, _ := strconv.ParseInt(fraction, 10, 64)
if len(fraction) == 2 {
fractionInt *= 10
} else if len(fraction) == 1 {
fractionInt *= 100
}
return minutes*60*1000 + seconds*1000 + fractionInt
}
func getSpotifyLyricsRateLimitUntil() time.Time {
spotifyLyricsRateLimitMu.RLock()
defer spotifyLyricsRateLimitMu.RUnlock()
return spotifyLyricsRateLimitedTil
}
func setSpotifyLyricsRateLimitUntil(until time.Time) {
spotifyLyricsRateLimitMu.Lock()
spotifyLyricsRateLimitedTil = until
spotifyLyricsRateLimitMu.Unlock()
}
func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
raw := strings.TrimSpace(retryAfter)
if raw == "" {
return now.Add(10 * time.Minute)
}
if sec, err := strconv.Atoi(raw); err == nil && sec > 0 {
return now.Add(time.Duration(sec) * time.Second)
}
if when, err := http.ParseTime(raw); err == nil && when.After(now) {
return when
}
return now.Add(10 * time.Minute)
}
func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
if len(lines) == 0 {
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
}
if syncType == "" {
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
syncType = "LINE_SYNCED"
} else {
syncType = "UNSYNCED"
}
}
return &LyricsResponse{
Lines: lines,
SyncType: syncType,
Instrumental: false,
PlainLyrics: plainLyrics,
Provider: "Spotify Lyrics API",
Source: "Spotify Lyrics API",
}, nil
}
func plainLyricsFromTimedLines(lines []LyricsLine) string {
parts := make([]string, 0, len(lines))
for _, line := range lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
parts = append(parts, words)
}
return strings.Join(parts, "\n")
}
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
var lrcPayload string
if err := json.Unmarshal(body, &lrcPayload); err == nil {
trimmed := strings.TrimSpace(lrcPayload)
if trimmed == "" {
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
}
lines := parseSyncedLyrics(trimmed)
if len(lines) > 0 {
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
}
plainLines := plainTextLyricsLines(trimmed)
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
}
var apiResp SpotifyLyricsAPIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
}
if apiResp.Error {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Spotify Lyrics API returned error"
}
return nil, fmt.Errorf("%s", msg)
}
lines := make([]LyricsLine, 0, len(apiResp.Lines))
for _, line := range apiResp.Lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
lines = append(lines, LyricsLine{
StartTimeMs: startMs,
Words: words,
EndTimeMs: 0,
})
}
for i := 0; i < len(lines)-1; i++ {
nextStart := lines[i+1].StartTimeMs
if nextStart > lines[i].StartTimeMs {
lines[i].EndTimeMs = nextStart
}
}
if len(lines) > 0 {
last := len(lines) - 1
if lines[last].EndTimeMs == 0 {
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
}
}
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
}
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
now := time.Now()
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
waitFor := int(math.Ceil(limitedUntil.Sub(now).Seconds()))
return nil, fmt.Errorf(
"Spotify Lyrics API cooldown active (%ds remaining after previous 429)",
waitFor,
)
}
spotifyID = strings.TrimSpace(spotifyID)
if spotifyID == "" {
return nil, fmt.Errorf("spotify ID is empty")
}
if parsed, err := parseSpotifyURI(spotifyID); err == nil && parsed.Type == "track" && parsed.ID != "" {
spotifyID = parsed.ID
}
apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
}
if resp.StatusCode != 200 {
if resp.StatusCode == http.StatusTooManyRequests {
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
setSpotifyLyricsRateLimitUntil(retryUntil)
}
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err == nil {
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
}
if msg, ok := payload["error"].(string); ok && strings.TrimSpace(msg) != "" {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
}
}
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
}
return parseSpotifyLyricsResponseBody(bodyBytes)
}
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse { func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
var bestSynced *LRCLibResponse var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse var bestPlain *LRCLibResponse
@@ -600,6 +364,18 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
return bestPlain return bestPlain
} }
func plainLyricsFromTimedLines(lines []LyricsLine) string {
parts := make([]string, 0, len(lines))
for _, line := range lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
parts = append(parts, words)
}
return strings.Join(parts, "\n")
}
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool { func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
diff := math.Abs(lrcDuration - targetDuration) diff := math.Abs(lrcDuration - targetDuration)
return diff <= durationToleranceSec return diff <= durationToleranceSec
@@ -609,8 +385,8 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
primaryArtist := normalizeArtistName(artistName) primaryArtist := normalizeArtistName(artistName)
fetchOptions := GetLyricsFetchOptions() fetchOptions := GetLyricsFetchOptions()
extManager := GetExtensionManager() extManager := getExtensionManager()
var extensionProviders []*ExtensionProviderWrapper var extensionProviders []*extensionProviderWrapper
if extManager != nil { if extManager != nil {
extensionProviders = extManager.GetLyricsProviders() extensionProviders = extManager.GetLyricsProviders()
} }
@@ -669,9 +445,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
var err error var err error
switch providerName { switch providerName {
case LyricsProviderSpotifyAPI:
lyrics, err = c.FetchLyricsFromSpotifyAPI(spotifyID)
case LyricsProviderLRCLIB: case LyricsProviderLRCLIB:
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec) lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
@@ -753,19 +526,16 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return nil, fmt.Errorf("lyrics not found from any source") return nil, fmt.Errorf("lyrics not found from any source")
} }
// tryLRCLIB attempts all LRCLIB search strategies (exact match, simplified, search).
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) { func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
var lyrics *LyricsResponse var lyrics *LyricsResponse
var err error var err error
// 1. Exact match with primary artist
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName) lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) { if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
lyrics.Source = "LRCLIB" lyrics.Source = "LRCLIB"
return lyrics, nil return lyrics, nil
} }
// 2. Exact match with full artist name
if primaryArtist != artistName { if primaryArtist != artistName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName) lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) { if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
@@ -774,7 +544,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
} }
} }
// 3. Simplified track name
if simplifiedTrack != trackName { if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack) lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) { if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
@@ -783,7 +552,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
} }
} }
// 4. Search by query
query := primaryArtist + " " + trackName query := primaryArtist + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) { if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
@@ -791,7 +559,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
return lyrics, nil return lyrics, nil
} }
// 5. Search with simplified track name
if simplifiedTrack != trackName { if simplifiedTrack != trackName {
query = primaryArtist + " " + simplifiedTrack query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
@@ -909,8 +676,6 @@ func lyricsHasUsableText(lyrics *LyricsResponse) bool {
return false return false
} }
// detectLyricsErrorPayload extracts human-readable error messages from
// JSON payloads returned by lyrics proxies when no lyric is available.
func detectLyricsErrorPayload(raw string) (string, bool) { func detectLyricsErrorPayload(raw string) (string, bool) {
trimmed := strings.TrimSpace(raw) trimmed := strings.TrimSpace(raw)
if trimmed == "" || !strings.HasPrefix(trimmed, "{") { if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
@@ -982,7 +747,7 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
builder.WriteString("[by:SpotiFLAC-Mobile]\n") builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
builder.WriteString("\n") builder.WriteString("\n")
if lyrics.SyncType == "LINE_SYNCED" { if lyrics.SyncType == "LINE_SYNCED" {
@@ -1035,8 +800,6 @@ func simplifyTrackName(name string) string {
return result return result
} }
// Add a loose fallback form for provider queries where punctuation
// and separators differ (e.g. "/" vs "_" vs spaces).
if loose := normalizeLooseTitle(result); loose != "" { if loose := normalizeLooseTitle(result); loose != "" {
return loose return loose
} }
-9
View File
@@ -11,8 +11,6 @@ import (
"time" "time"
) )
// AppleMusicClient fetches lyrics from Apple Music.
// Uses Paxsenix endpoints for search and lyrics.
type AppleMusicClient struct { type AppleMusicClient struct {
httpClient *http.Client httpClient *http.Client
} }
@@ -25,7 +23,6 @@ type appleMusicSearchResult struct {
Duration int `json:"duration"` Duration int `json:"duration"`
} }
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
type paxResponse struct { type paxResponse struct {
Type string `json:"type"` // "Syllable" or "Line" Type string `json:"type"` // "Syllable" or "Line"
Content []paxLyrics `json:"content"` // List of lyric lines Content []paxLyrics `json:"content"` // List of lyric lines
@@ -103,7 +100,6 @@ func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackNam
return &results[bestIndex] return &results[bestIndex]
} }
// SearchSong searches for a song on Apple Music and returns its ID.
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) { func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := trackName + " " + artistName query := trackName + " " + artistName
if strings.TrimSpace(query) == "" { if strings.TrimSpace(query) == "" {
@@ -144,7 +140,6 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
return strings.TrimSpace(best.ID), nil return strings.TrimSpace(best.ID), nil
} }
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) { func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID) lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
@@ -252,7 +247,6 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
return strings.TrimSpace(sb.String()) return strings.TrimSpace(sb.String())
} }
// FetchLyrics searches Apple Music and returns parsed LyricsResponse.
func (c *AppleMusicClient) FetchLyrics( func (c *AppleMusicClient) FetchLyrics(
trackName, trackName,
artistName string, artistName string,
@@ -272,10 +266,8 @@ func (c *AppleMusicClient) FetchLyrics(
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg) return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
} }
// Try to parse as pax format (word-by-word or line)
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord) lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
if err != nil { if err != nil {
// If pax parsing fails, try to parse as direct LRC text
lrcText = rawLyrics lrcText = rawLyrics
} }
@@ -289,7 +281,6 @@ func (c *AppleMusicClient) FetchLyrics(
}, nil }, nil
} }
// Fall back to plain text if no timestamps found
resultLines := plainTextLyricsLines(lrcText) resultLines := plainTextLyricsLines(lrcText)
if len(resultLines) > 0 { if len(resultLines) > 0 {
-4
View File
@@ -11,8 +11,6 @@ import (
"time" "time"
) )
// MusixmatchClient fetches lyrics from Musixmatch via a proxy server.
// The proxy handles Musixmatch authentication internally.
type MusixmatchClient struct { type MusixmatchClient struct {
httpClient *http.Client httpClient *http.Client
baseURL string baseURL string
@@ -114,7 +112,6 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura
return "", fmt.Errorf("failed to decode musixmatch response") return "", fmt.Errorf("failed to decode musixmatch response")
} }
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) { func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
lang := strings.ToLower(strings.TrimSpace(language)) lang := strings.ToLower(strings.TrimSpace(language))
if lang == "" { if lang == "" {
@@ -151,7 +148,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, d
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang) return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
} }
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) { func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" { if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred) localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
-5
View File
@@ -9,7 +9,6 @@ import (
"time" "time"
) )
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
type NeteaseClient struct { type NeteaseClient struct {
httpClient *http.Client httpClient *http.Client
} }
@@ -51,7 +50,6 @@ func NewNeteaseClient() *NeteaseClient {
} }
} }
// SearchSong searches for a song on Netease and returns the song ID.
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) { func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
query := trackName + " " + artistName query := trackName + " " + artistName
if strings.TrimSpace(query) == "" { if strings.TrimSpace(query) == "" {
@@ -96,7 +94,6 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
return searchResp.Result.Songs[0].ID, nil return searchResp.Result.Songs[0].ID, nil
} }
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) { func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics" lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
params := url.Values{} params := url.Values{}
@@ -146,7 +143,6 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
return lyric, nil return lyric, nil
} }
// FetchLyrics searches for a track and returns parsed LyricsResponse.
func (c *NeteaseClient) FetchLyrics( func (c *NeteaseClient) FetchLyrics(
trackName, trackName,
artistName string, artistName string,
@@ -166,7 +162,6 @@ func (c *NeteaseClient) FetchLyrics(
lines := parseSyncedLyrics(lrcText) lines := parseSyncedLyrics(lrcText)
if len(lines) == 0 { if len(lines) == 0 {
// May be plain text lyrics without timestamps
plainLines := strings.Split(lrcText, "\n") plainLines := strings.Split(lrcText, "\n")
for _, line := range plainLines { for _, line := range plainLines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
-4
View File
@@ -10,8 +10,6 @@ import (
"time" "time"
) )
// QQMusicClient fetches lyrics from QQ Music.
// Uses Paxsenix metadata lookup for lyrics.
type QQMusicClient struct { type QQMusicClient struct {
httpClient *http.Client httpClient *http.Client
} }
@@ -34,7 +32,6 @@ func NewQQMusicClient() *QQMusicClient {
} }
} }
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) { func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
payload := qqLyricsMetadataRequest{ payload := qqLyricsMetadataRequest{
Artist: []string{artistName}, Artist: []string{artistName},
@@ -93,7 +90,6 @@ func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (st
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
} }
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
func (c *QQMusicClient) FetchLyrics( func (c *QQMusicClient) FetchLyrics(
trackName, trackName,
artistName string, artistName string,
+489 -127
View File
@@ -11,6 +11,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
@@ -19,6 +20,10 @@ import (
"github.com/go-flac/go-flac/v2" "github.com/go-flac/go-flac/v2"
) )
const artistTagModeSplitVorbis = "split_vorbis"
var artistTagSplitPattern = regexp.MustCompile(`\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?\s*`)
func detectCoverMIME(coverPath string, coverData []byte) string { func detectCoverMIME(coverPath string, coverData []byte) string {
// Prefer magic-byte detection over file extension. // Prefer magic-byte detection over file extension.
// Some providers return non-JPEG data behind .jpg URLs. // Some providers return non-JPEG data behind .jpg URLs.
@@ -96,22 +101,30 @@ func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock,
} }
type Metadata struct { type Metadata struct {
Title string Title string
Artist string Artist string
Album string Album string
AlbumArtist string AlbumArtist string
Date string ArtistTagMode string
TrackNumber int Date string
TotalTracks int TrackNumber int
DiscNumber int TotalTracks int
ISRC string DiscNumber int
Description string TotalDiscs int
Lyrics string ISRC string
Genre string Description string
Label string Lyrics string
Copyright string Genre string
Composer string Label string
Comment string Copyright string
Composer string
Comment string
// ReplayGain fields (stored as Vorbis Comments in FLAC)
ReplayGainTrackGain string // e.g. "-6.50 dB"
ReplayGainTrackPeak string // e.g. "0.988831"
ReplayGainAlbumGain string // e.g. "-7.20 dB"
ReplayGainAlbumPeak string // e.g. "1.000000"
} }
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
@@ -138,56 +151,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
cmt = flacvorbis.New() cmt = flacvorbis.New()
} }
setComment(cmt, "TITLE", metadata.Title) writeVorbisMetadata(cmt, metadata)
setComment(cmt, "ARTIST", metadata.Artist)
setComment(cmt, "ALBUM", metadata.Album)
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
setComment(cmt, "DATE", metadata.Date)
if metadata.TrackNumber > 0 {
if metadata.TotalTracks > 0 {
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
} else {
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
}
}
if metadata.DiscNumber > 0 {
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
}
if metadata.ISRC != "" {
setComment(cmt, "ISRC", metadata.ISRC)
}
if metadata.Description != "" {
setComment(cmt, "DESCRIPTION", metadata.Description)
}
if metadata.Lyrics != "" {
setComment(cmt, "LYRICS", metadata.Lyrics)
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
}
if metadata.Genre != "" {
setComment(cmt, "GENRE", metadata.Genre)
}
if metadata.Label != "" {
setComment(cmt, "ORGANIZATION", metadata.Label)
}
if metadata.Copyright != "" {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
@@ -247,56 +211,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
cmt = flacvorbis.New() cmt = flacvorbis.New()
} }
setComment(cmt, "TITLE", metadata.Title) writeVorbisMetadata(cmt, metadata)
setComment(cmt, "ARTIST", metadata.Artist)
setComment(cmt, "ALBUM", metadata.Album)
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
setComment(cmt, "DATE", metadata.Date)
if metadata.TrackNumber > 0 {
if metadata.TotalTracks > 0 {
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
} else {
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
}
}
if metadata.DiscNumber > 0 {
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
}
if metadata.ISRC != "" {
setComment(cmt, "ISRC", metadata.ISRC)
}
if metadata.Description != "" {
setComment(cmt, "DESCRIPTION", metadata.Description)
}
if metadata.Lyrics != "" {
setComment(cmt, "LYRICS", metadata.Lyrics)
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
}
if metadata.Genre != "" {
setComment(cmt, "GENRE", metadata.Genre)
}
if metadata.Label != "" {
setComment(cmt, "ORGANIZATION", metadata.Label)
}
if metadata.Copyright != "" {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
@@ -339,9 +254,15 @@ func ReadMetadata(filePath string) (*Metadata, error) {
} }
metadata.Title = getComment(cmt, "TITLE") metadata.Title = getComment(cmt, "TITLE")
metadata.Artist = getComment(cmt, "ARTIST") metadata.Artist = getJoinedComment(cmt, "ARTIST")
metadata.Album = getComment(cmt, "ALBUM") metadata.Album = getComment(cmt, "ALBUM")
metadata.AlbumArtist = getComment(cmt, "ALBUMARTIST") metadata.AlbumArtist = getJoinedComment(cmt, "ALBUMARTIST")
if metadata.AlbumArtist == "" {
metadata.AlbumArtist = getJoinedComment(cmt, "ALBUM ARTIST")
}
if metadata.AlbumArtist == "" {
metadata.AlbumArtist = getJoinedComment(cmt, "ALBUM_ARTIST")
}
metadata.Date = getComment(cmt, "DATE") metadata.Date = getComment(cmt, "DATE")
metadata.ISRC = getComment(cmt, "ISRC") metadata.ISRC = getComment(cmt, "ISRC")
metadata.Description = getComment(cmt, "DESCRIPTION") metadata.Description = getComment(cmt, "DESCRIPTION")
@@ -353,23 +274,23 @@ func ReadMetadata(filePath string) (*Metadata, error) {
trackNum := getComment(cmt, "TRACKNUMBER") trackNum := getComment(cmt, "TRACKNUMBER")
if trackNum != "" { if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
} }
if metadata.TrackNumber == 0 { if metadata.TrackNumber == 0 {
trackNum = getComment(cmt, "TRACK") trackNum = getComment(cmt, "TRACK")
if trackNum != "" { if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
} }
} }
discNum := getComment(cmt, "DISCNUMBER") discNum := getComment(cmt, "DISCNUMBER")
if discNum != "" { if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
} }
if metadata.DiscNumber == 0 { if metadata.DiscNumber == 0 {
discNum = getComment(cmt, "DISC") discNum = getComment(cmt, "DISC")
if discNum != "" { if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
} }
} }
@@ -379,10 +300,21 @@ func ReadMetadata(filePath string) (*Metadata, error) {
metadata.Genre = getComment(cmt, "GENRE") metadata.Genre = getComment(cmt, "GENRE")
metadata.Label = getComment(cmt, "ORGANIZATION") metadata.Label = getComment(cmt, "ORGANIZATION")
if metadata.Label == "" {
metadata.Label = getComment(cmt, "LABEL")
}
if metadata.Label == "" {
metadata.Label = getComment(cmt, "PUBLISHER")
}
metadata.Copyright = getComment(cmt, "COPYRIGHT") metadata.Copyright = getComment(cmt, "COPYRIGHT")
metadata.Composer = getComment(cmt, "COMPOSER") metadata.Composer = getComment(cmt, "COMPOSER")
metadata.Comment = getComment(cmt, "COMMENT") metadata.Comment = getComment(cmt, "COMMENT")
metadata.ReplayGainTrackGain = getComment(cmt, "REPLAYGAIN_TRACK_GAIN")
metadata.ReplayGainTrackPeak = getComment(cmt, "REPLAYGAIN_TRACK_PEAK")
metadata.ReplayGainAlbumGain = getComment(cmt, "REPLAYGAIN_ALBUM_GAIN")
metadata.ReplayGainAlbumPeak = getComment(cmt, "REPLAYGAIN_ALBUM_PEAK")
break break
} }
} }
@@ -390,10 +322,332 @@ func ReadMetadata(filePath string) (*Metadata, error) {
return metadata, nil return metadata, nil
} }
// EditFlacFields opens a FLAC file and updates only the Vorbis Comment keys
// that are explicitly present in the fields map. Keys present with a non-empty
// value are set; keys present with an empty value are removed (cleared). Keys
// absent from the map are left untouched. This is the correct function for
// partial edits (e.g. writing only ReplayGain tags) and full editor saves alike.
func EditFlacFields(filePath string, fields map[string]string) error {
f, err := flac.ParseFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
}
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
artistMode := fields["artist_tag_mode"]
// Mapping from fields-map key → one or more Vorbis Comment keys.
// Each entry is handled with set-or-clear semantics.
simpleKeys := map[string]string{
"title": "TITLE",
"album": "ALBUM",
"date": "DATE",
"isrc": "ISRC",
"genre": "GENRE",
"label": "ORGANIZATION",
"copyright": "COPYRIGHT",
"composer": "COMPOSER",
"comment": "COMMENT",
"replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
"replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
"replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
"replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK",
}
for fieldKey, vorbisKey := range simpleKeys {
if v, ok := fields[fieldKey]; ok {
setOrClearComment(cmt, vorbisKey, v)
}
}
// Remove known aliases for fields that were just written/cleared, so that
// tags from other taggers (e.g. LABEL, PUBLISHER, ALBUM ARTIST) don't
// conflict with the canonical keys we use.
aliasCleanup := map[string][]string{
"label": {"LABEL", "PUBLISHER"}, // canonical: ORGANIZATION
"date": {"YEAR"}, // canonical: DATE
"genre": {}, // no common aliases
"copyright": {},
}
for fieldKey, aliases := range aliasCleanup {
if _, ok := fields[fieldKey]; ok {
for _, alias := range aliases {
removeCommentKey(cmt, alias)
}
}
}
// Artist fields: use split-artist logic when mode is set.
if v, ok := fields["artist"]; ok {
setOrClearArtistComments(cmt, "ARTIST", v, artistMode)
}
if v, ok := fields["album_artist"]; ok {
setOrClearArtistComments(cmt, "ALBUMARTIST", v, artistMode)
// Remove aliases from other taggers.
removeCommentKey(cmt, "ALBUM ARTIST")
removeCommentKey(cmt, "ALBUM_ARTIST")
}
// Track/disc numbers: present + empty → clear; when only totals are edited,
// preserve the current index number and rewrite the combined value.
if _, ok := fields["track_number"]; ok || fields["track_total"] != "" || hasMapKey(fields, "track_total") {
currentTrackNum, currentTotalTracks := parseIndexPair(getComment(cmt, "TRACKNUMBER"))
if currentTrackNum == 0 && currentTotalTracks == 0 {
currentTrackNum, currentTotalTracks = parseIndexPair(getComment(cmt, "TRACK"))
}
if v, ok := fields["track_number"]; ok {
currentTrackNum = parsePositiveInt(v)
}
if v, ok := fields["track_total"]; ok {
currentTotalTracks = parsePositiveInt(v)
}
if currentTrackNum > 0 {
setOrClearComment(cmt, "TRACKNUMBER", formatIndexValue(currentTrackNum, currentTotalTracks))
} else {
removeCommentKey(cmt, "TRACKNUMBER")
}
removeCommentKey(cmt, "TRACK") // alias
}
if _, ok := fields["disc_number"]; ok || fields["disc_total"] != "" || hasMapKey(fields, "disc_total") {
currentDiscNum, currentTotalDiscs := parseIndexPair(getComment(cmt, "DISCNUMBER"))
if currentDiscNum == 0 && currentTotalDiscs == 0 {
currentDiscNum, currentTotalDiscs = parseIndexPair(getComment(cmt, "DISC"))
}
if v, ok := fields["disc_number"]; ok {
currentDiscNum = parsePositiveInt(v)
}
if v, ok := fields["disc_total"]; ok {
currentTotalDiscs = parsePositiveInt(v)
}
if currentDiscNum > 0 {
setOrClearComment(cmt, "DISCNUMBER", formatIndexValue(currentDiscNum, currentTotalDiscs))
} else {
removeCommentKey(cmt, "DISCNUMBER")
}
removeCommentKey(cmt, "DISC") // alias
}
// Lyrics: set both LYRICS + UNSYNCEDLYRICS, or clear both.
if v, ok := fields["lyrics"]; ok {
if v != "" {
setOrClearComment(cmt, "LYRICS", v)
setOrClearComment(cmt, "UNSYNCEDLYRICS", v)
} else {
removeCommentKey(cmt, "LYRICS")
removeCommentKey(cmt, "UNSYNCEDLYRICS")
}
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
} else {
f.Meta = append(f.Meta, &cmtBlock)
}
coverPath := strings.TrimSpace(fields["cover_path"])
if coverPath != "" && fileExists(coverPath) {
coverData, err := os.ReadFile(coverPath)
if err == nil && len(coverData) > 0 {
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
}
}
picBlock, err := buildPictureBlock("", coverData)
if err == nil {
f.Meta = append(f.Meta, &picBlock)
}
}
}
return f.Save(filePath)
}
// writeVorbisMetadata writes all metadata fields to a Vorbis Comment block.
// Empty/zero values are simply skipped (not written, not cleared). This is
// used by the download embedding path where absent fields should preserve any
// existing values. The editor path uses EditFlacFields() instead.
func writeVorbisMetadata(cmt *flacvorbis.MetaDataBlockVorbisComment, metadata Metadata) {
setComment(cmt, "TITLE", metadata.Title)
setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
setComment(cmt, "ALBUM", metadata.Album)
setArtistComments(cmt, "ALBUMARTIST", metadata.AlbumArtist, metadata.ArtistTagMode)
setComment(cmt, "DATE", metadata.Date)
if metadata.TrackNumber > 0 {
setComment(cmt, "TRACKNUMBER", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
}
if metadata.DiscNumber > 0 {
setComment(cmt, "DISCNUMBER", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
}
if metadata.ISRC != "" {
setComment(cmt, "ISRC", metadata.ISRC)
}
if metadata.Description != "" {
setComment(cmt, "DESCRIPTION", metadata.Description)
}
if metadata.Lyrics != "" {
setComment(cmt, "LYRICS", metadata.Lyrics)
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
}
if metadata.Genre != "" {
setComment(cmt, "GENRE", metadata.Genre)
}
if metadata.Label != "" {
setComment(cmt, "ORGANIZATION", metadata.Label)
}
if metadata.Copyright != "" {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
setComment(cmt, "REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
setComment(cmt, "REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
setComment(cmt, "REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
setComment(cmt, "REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak)
}
func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) { func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" { if value == "" {
return return
} }
removeCommentKey(cmt, key)
cmt.Comments = append(cmt.Comments, key+"="+value)
}
// setOrClearComment writes a Vorbis Comment, or removes the key if value is
// empty. Used by the metadata editor path where empty means "delete this tag".
func setOrClearComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" {
removeCommentKey(cmt, key)
return
}
removeCommentKey(cmt, key)
cmt.Comments = append(cmt.Comments, key+"="+value)
}
func setArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, mode string) {
if value == "" {
return
}
values := []string{value}
if shouldSplitVorbisArtistTags(mode) {
values = splitArtistTagValues(value)
}
if len(values) == 0 {
return
}
removeCommentKey(cmt, key)
for _, artist := range values {
if strings.TrimSpace(artist) == "" {
continue
}
cmt.Comments = append(cmt.Comments, key+"="+artist)
}
}
// setOrClearArtistComments writes artist Vorbis Comments, or removes the key
// if value is empty. Used by the metadata editor path.
func setOrClearArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, mode string) {
if value == "" {
removeCommentKey(cmt, key)
return
}
values := []string{value}
if shouldSplitVorbisArtistTags(mode) {
values = splitArtistTagValues(value)
}
if len(values) == 0 {
removeCommentKey(cmt, key)
return
}
removeCommentKey(cmt, key)
for _, artist := range values {
if strings.TrimSpace(artist) == "" {
continue
}
cmt.Comments = append(cmt.Comments, key+"="+artist)
}
}
// RewriteSplitArtistTags opens a FLAC file and rewrites the ARTIST and
// ALBUMARTIST Vorbis comments as multiple separate entries (one per artist).
// This is needed because FFmpeg's -metadata flag deduplicates keys, so only
// the last value survives when multiple -metadata ARTIST=X flags are used.
// The native go-flac writer correctly handles multiple Vorbis comments.
func RewriteSplitArtistTags(filePath, artist, albumArtist string) error {
if !shouldSplitVorbisArtistTags(artistTagModeSplitVorbis) {
return nil
}
f, err := flac.ParseFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
}
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
setArtistComments(cmt, "ARTIST", artist, artistTagModeSplitVorbis)
setArtistComments(cmt, "ALBUMARTIST", albumArtist, artistTagModeSplitVorbis)
cmtMeta := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtMeta
} else {
f.Meta = append(f.Meta, &cmtMeta)
}
return f.Save(filePath)
}
func removeCommentKey(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) {
keyUpper := strings.ToUpper(key) keyUpper := strings.ToUpper(key)
for i := len(cmt.Comments) - 1; i >= 0; i-- { for i := len(cmt.Comments) - 1; i >= 0; i-- {
comment := cmt.Comments[i] comment := cmt.Comments[i]
@@ -405,20 +659,85 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
} }
} }
} }
cmt.Comments = append(cmt.Comments, key+"="+value)
} }
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string { func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
values := getCommentValues(cmt, key)
if len(values) == 0 {
return ""
}
return values[0]
}
func getJoinedComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
return joinVorbisCommentValues(getCommentValues(cmt, key))
}
func getCommentValues(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) []string {
keyUpper := strings.ToUpper(key) + "=" keyUpper := strings.ToUpper(key) + "="
values := make([]string, 0, 1)
for _, comment := range cmt.Comments { for _, comment := range cmt.Comments {
if len(comment) > len(key) { if len(comment) > len(key) {
commentUpper := strings.ToUpper(comment[:len(key)+1]) commentUpper := strings.ToUpper(comment[:len(key)+1])
if commentUpper == keyUpper { if commentUpper == keyUpper {
return comment[len(key)+1:] values = append(values, comment[len(key)+1:])
} }
} }
} }
return "" return values
}
func shouldSplitVorbisArtistTags(mode string) bool {
return strings.EqualFold(strings.TrimSpace(mode), artistTagModeSplitVorbis)
}
func splitArtistTagValues(rawArtists string) []string {
trimmed := strings.TrimSpace(rawArtists)
if trimmed == "" {
return nil
}
parts := artistTagSplitPattern.Split(trimmed, -1)
values := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
artist := strings.TrimSpace(part)
if artist == "" {
continue
}
key := strings.ToLower(artist)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
values = append(values, artist)
}
if len(values) > 0 {
return values
}
return []string{trimmed}
}
func joinVorbisCommentValues(values []string) string {
if len(values) == 0 {
return ""
}
joined := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
joined = append(joined, trimmed)
}
return strings.Join(joined, ", ")
} }
func fileExists(path string) bool { func fileExists(path string) bool {
@@ -644,9 +963,9 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) {
case "\xa9lyr": case "\xa9lyr":
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size()) metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
case "trkn": case "trkn":
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size()) metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fi.Size())
case "disk": case "disk":
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size()) metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fi.Size())
case "----": case "----":
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size()) name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
if freeformErr == nil { if freeformErr == nil {
@@ -671,6 +990,14 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) {
if metadata.Lyrics == "" { if metadata.Lyrics == "" {
metadata.Lyrics = value metadata.Lyrics = value
} }
case "REPLAYGAIN_TRACK_GAIN":
metadata.ReplayGainTrackGain = value
case "REPLAYGAIN_TRACK_PEAK":
metadata.ReplayGainTrackPeak = value
case "REPLAYGAIN_ALBUM_GAIN":
metadata.ReplayGainAlbumGain = value
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = value
} }
} }
} }
@@ -833,6 +1160,41 @@ func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, erro
return int(binary.BigEndian.Uint16(payload[2:4])), nil return int(binary.BigEndian.Uint16(payload[2:4])), nil
} }
func readM4AIndexPair(f *os.File, parent atomHeader, fileSize int64) (int, int, error) {
payload, err := readM4ADataPayload(f, parent, fileSize)
if err != nil {
return 0, 0, err
}
if len(payload) < 6 {
return 0, 0, fmt.Errorf("index payload too short in %s", parent.typ)
}
return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6])), nil
}
func parsePositiveInt(value string) int {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
n, _ := strconv.Atoi(value)
return n
}
func formatIndexValue(number, total int) string {
if number <= 0 {
return ""
}
if total > 0 {
return fmt.Sprintf("%d/%d", number, total)
}
return strconv.Itoa(number)
}
func hasMapKey(fields map[string]string, key string) bool {
_, ok := fields[key]
return ok
}
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) { func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
start := parent.offset + parent.headerSize start := parent.offset + parent.headerSize
end := parent.offset + parent.size end := parent.offset + parent.size
+67
View File
@@ -0,0 +1,67 @@
package gobackend
import (
"bytes"
"encoding/binary"
"slices"
"testing"
"github.com/go-flac/flacvorbis/v2"
)
func TestSplitArtistTagValues(t *testing.T) {
got := splitArtistTagValues("Artist A, Artist B feat. Artist C & Artist B")
want := []string{"Artist A", "Artist B", "Artist C"}
if !slices.Equal(got, want) {
t.Fatalf("splitArtistTagValues() = %#v, want %#v", got, want)
}
}
func TestSetArtistCommentsSplitVorbis(t *testing.T) {
cmt := flacvorbis.New()
setArtistComments(cmt, "ARTIST", "Artist A, Artist B", artistTagModeSplitVorbis)
got := getCommentValues(cmt, "ARTIST")
want := []string{"Artist A", "Artist B"}
if !slices.Equal(got, want) {
t.Fatalf("getCommentValues(ARTIST) = %#v, want %#v", got, want)
}
}
func TestParseVorbisCommentsJoinsRepeatedArtists(t *testing.T) {
metadata := &AudioMetadata{}
parseVorbisComments(
buildVorbisCommentPayload(
[]string{
"TITLE=Song",
"ARTIST=Artist A",
"ARTIST=Artist B",
"ALBUMARTIST=Album Artist A",
"ALBUMARTIST=Album Artist B",
},
),
metadata,
)
if metadata.Title != "Song" {
t.Fatalf("title = %q", metadata.Title)
}
if metadata.Artist != "Artist A, Artist B" {
t.Fatalf("artist = %q", metadata.Artist)
}
if metadata.AlbumArtist != "Album Artist A, Album Artist B" {
t.Fatalf("album artist = %q", metadata.AlbumArtist)
}
}
func buildVorbisCommentPayload(comments []string) []byte {
var buf bytes.Buffer
_ = binary.Write(&buf, binary.LittleEndian, uint32(len("spotiflac")))
buf.WriteString("spotiflac")
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(comments)))
for _, comment := range comments {
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(comment)))
buf.WriteString(comment)
}
return buf.Bytes()
}
+149
View File
@@ -0,0 +1,149 @@
package gobackend
import "time"
type cacheEntry struct {
data interface{}
expiresAt time.Time
}
func (e *cacheEntry) isExpired() bool {
return time.Now().After(e.expiresAt)
}
type TrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"`
Name string `json:"name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist,omitempty"`
DurationMS int `json:"duration_ms"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TrackNumber int `json:"track_number"`
TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
AlbumID string `json:"album_id,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
AlbumType string `json:"album_type,omitempty"`
Composer string `json:"composer,omitempty"`
}
type AlbumTrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"`
Name string `json:"name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist,omitempty"`
DurationMS int `json:"duration_ms"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TrackNumber int `json:"track_number"`
TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"`
AlbumType string `json:"album_type,omitempty"`
Composer string `json:"composer,omitempty"`
}
type AlbumInfoMetadata struct {
TotalTracks int `json:"total_tracks"`
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
Artists string `json:"artists"`
ArtistId string `json:"artist_id,omitempty"`
Images string `json:"images"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
}
type AlbumResponsePayload struct {
AlbumInfo AlbumInfoMetadata `json:"album_info"`
TrackList []AlbumTrackMetadata `json:"track_list"`
}
type PlaylistInfoMetadata struct {
Name string `json:"name,omitempty"`
Images string `json:"images,omitempty"`
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
Owner struct {
DisplayName string `json:"display_name"`
Name string `json:"name"`
Images string `json:"images"`
} `json:"owner"`
}
type PlaylistResponsePayload struct {
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
TrackList []AlbumTrackMetadata `json:"track_list"`
}
type ArtistInfoMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Images string `json:"images"`
Followers int `json:"followers"`
Popularity int `json:"popularity"`
}
type ArtistAlbumMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
TotalTracks int `json:"total_tracks"`
Images string `json:"images"`
AlbumType string `json:"album_type"`
Artists string `json:"artists"`
}
type ArtistResponsePayload struct {
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
Albums []ArtistAlbumMetadata `json:"albums"`
}
type TrackResponse struct {
Track TrackMetadata `json:"track"`
}
type SearchArtistResult struct {
ID string `json:"id"`
Name string `json:"name"`
Images string `json:"images"`
Followers int `json:"followers"`
Popularity int `json:"popularity"`
}
type SearchAlbumResult struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TotalTracks int `json:"total_tracks"`
AlbumType string `json:"album_type"`
}
type SearchPlaylistResult struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Images string `json:"images"`
TotalTracks int `json:"total_tracks"`
}
type SearchAllResult struct {
Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"`
Albums []SearchAlbumResult `json:"albums"`
Playlists []SearchPlaylistResult `json:"playlists"`
}
+537 -63
View File
@@ -13,6 +13,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -43,11 +44,12 @@ var (
) )
const ( const (
qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id=" qobuzAPIBaseURL = "https://api.zarz.moe/v1/qbz/"
qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query=" qobuzTrackGetBaseURL = qobuzAPIBaseURL + "track/get?track_id="
qobuzAlbumGetBaseURL = "https://www.qobuz.com/api.json/0.2/album/get?album_id=" qobuzTrackSearchBaseURL = qobuzAPIBaseURL + "track/search?query="
qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id=" qobuzAlbumGetBaseURL = qobuzAPIBaseURL + "album/get?album_id="
qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id=" qobuzArtistGetBaseURL = qobuzAPIBaseURL + "artist/get?artist_id="
qobuzPlaylistGetBaseURL = qobuzAPIBaseURL + "playlist/get?playlist_id="
qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/" qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/"
qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/" qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/"
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
@@ -57,21 +59,19 @@ const (
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/" qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id=" qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
qobuzDebugKeyXORMask = byte(0x5A)
qobuzFallbackAPIBaseURL = "https://api.zarz.moe/v1/qbz2/"
qobuzFallbackTrackGetBaseURL = qobuzFallbackAPIBaseURL + "track/get?track_id="
qobuzFallbackTrackSearchBaseURL = qobuzFallbackAPIBaseURL + "track/search?query="
qobuzFallbackAlbumGetBaseURL = qobuzFallbackAPIBaseURL + "album/get?album_id="
qobuzFallbackArtistGetBaseURL = qobuzFallbackAPIBaseURL + "artist/get?artist_id="
qobuzFallbackPlaylistGetBaseURL = qobuzFallbackAPIBaseURL + "playlist/get?playlist_id="
) )
var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`) var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`)
var qobuzArtistAlbumIDRegex = regexp.MustCompile(`data-itemtype="album"\s+data-itemId="([A-Za-z0-9]+)"`) var qobuzArtistAlbumIDRegex = regexp.MustCompile(`data-itemtype="album"\s+data-itemId="([A-Za-z0-9]+)"`)
var qobuzLocaleSegmentRegex = regexp.MustCompile(`^[a-z]{2}-[a-z]{2}$`) var qobuzLocaleSegmentRegex = regexp.MustCompile(`^[a-z]{2}-[a-z]{2}$`)
var qobuzDebugKeyObfuscated = []byte{
0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b,
0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 0x3b,
0x34, 0x3e, 0x34, 0x35, 0x35, 0x34, 0x3f, 0x39, 0x35, 0x37,
0x3f, 0x29, 0x3f, 0x2c, 0x3f, 0x34, 0x39, 0x36, 0x35, 0x29,
0x3f,
}
type QobuzTrack struct { type QobuzTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -785,12 +785,21 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
resp, err := DoRequestWithUserAgent(q.client, req) resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil { if err != nil {
if isQobuzPrimaryUnavailable(err) {
GoLog("[Qobuz] Primary API unavailable for track %d, trying qbz2 fallback: %v\n", trackID, err)
return q.getTrackByIDViaMusicDL(trackID)
}
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("get track failed: HTTP %d", resp.StatusCode) primaryErr := fmt.Errorf("get track failed: HTTP %d", resp.StatusCode)
if isQobuzPrimaryUnavailable(primaryErr) {
GoLog("[Qobuz] Primary API unavailable for track %d, trying qbz2 fallback: %v\n", trackID, primaryErr)
return q.getTrackByIDViaMusicDL(trackID)
}
return nil, primaryErr
} }
var track QobuzTrack var track QobuzTrack
@@ -801,6 +810,16 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
return &track, nil return &track, nil
} }
func (q *QobuzDownloader) getTrackByIDViaMusicDL(trackID int64) (*QobuzTrack, error) {
requestURL := fmt.Sprintf("%s%d", qobuzFallbackTrackGetBaseURL, trackID)
var track QobuzTrack
if err := q.getQobuzJSON(requestURL, &track); err != nil {
return nil, fmt.Errorf("qbz2 fallback also failed for track %d: %w", trackID, err)
}
GoLog("[Qobuz] qbz2 fallback succeeded for track %d\n", trackID)
return &track, nil
}
func (q *QobuzDownloader) getQobuzJSON(requestURL string, target interface{}) error { func (q *QobuzDownloader) getQobuzJSON(requestURL string, target interface{}) error {
req, err := http.NewRequest("GET", requestURL, nil) req, err := http.NewRequest("GET", requestURL, nil)
if err != nil { if err != nil {
@@ -841,6 +860,25 @@ func (q *QobuzDownloader) getQobuzBody(requestURL string) ([]byte, error) {
return io.ReadAll(resp.Body) return io.ReadAll(resp.Body)
} }
func isQobuzPrimaryUnavailable(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "HTTP 429") ||
strings.Contains(errStr, "HTTP 5") ||
strings.Contains(errStr, "rate limit") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "no such host") ||
strings.Contains(errStr, "i/o timeout") ||
strings.Contains(errStr, "deadline exceeded") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "TLS handshake") ||
strings.Contains(errStr, "server misbehaving") ||
strings.Contains(errStr, "network is unreachable")
}
func extractQobuzAlbumIDsFromArtistHTML(body []byte) []string { func extractQobuzAlbumIDsFromArtistHTML(body []byte) []string {
matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1) matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1)
if len(matches) == 0 { if len(matches) == 0 {
@@ -870,20 +908,48 @@ func (q *QobuzDownloader) getAlbumDetails(albumID string) (*qobuzAlbumDetails, e
requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzAlbumGetBaseURL, url.QueryEscape(strings.TrimSpace(albumID)), q.appID) requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzAlbumGetBaseURL, url.QueryEscape(strings.TrimSpace(albumID)), q.appID)
var album qobuzAlbumDetails var album qobuzAlbumDetails
if err := q.getQobuzJSON(requestURL, &album); err != nil { if err := q.getQobuzJSON(requestURL, &album); err != nil {
if isQobuzPrimaryUnavailable(err) {
GoLog("[Qobuz] Primary API unavailable for album %s, trying qbz2 fallback: %v\n", albumID, err)
return q.getAlbumDetailsViaMusicDL(albumID)
}
return nil, err return nil, err
} }
return &album, nil return &album, nil
} }
func (q *QobuzDownloader) getAlbumDetailsViaMusicDL(albumID string) (*qobuzAlbumDetails, error) {
requestURL := fmt.Sprintf("%s%s", qobuzFallbackAlbumGetBaseURL, url.QueryEscape(strings.TrimSpace(albumID)))
var album qobuzAlbumDetails
if err := q.getQobuzJSON(requestURL, &album); err != nil {
return nil, fmt.Errorf("qbz2 fallback also failed for album %s: %w", albumID, err)
}
GoLog("[Qobuz] qbz2 fallback succeeded for album %s\n", albumID)
return &album, nil
}
func (q *QobuzDownloader) getArtistDetails(artistID string) (*qobuzArtistDetails, error) { func (q *QobuzDownloader) getArtistDetails(artistID string) (*qobuzArtistDetails, error) {
requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzArtistGetBaseURL, url.QueryEscape(strings.TrimSpace(artistID)), q.appID) requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzArtistGetBaseURL, url.QueryEscape(strings.TrimSpace(artistID)), q.appID)
var artist qobuzArtistDetails var artist qobuzArtistDetails
if err := q.getQobuzJSON(requestURL, &artist); err != nil { if err := q.getQobuzJSON(requestURL, &artist); err != nil {
if isQobuzPrimaryUnavailable(err) {
GoLog("[Qobuz] Primary API unavailable for artist %s, trying qbz2 fallback: %v\n", artistID, err)
return q.getArtistDetailsViaMusicDL(artistID)
}
return nil, err return nil, err
} }
return &artist, nil return &artist, nil
} }
func (q *QobuzDownloader) getArtistDetailsViaMusicDL(artistID string) (*qobuzArtistDetails, error) {
requestURL := fmt.Sprintf("%s%s", qobuzFallbackArtistGetBaseURL, url.QueryEscape(strings.TrimSpace(artistID)))
var artist qobuzArtistDetails
if err := q.getQobuzJSON(requestURL, &artist); err != nil {
return nil, fmt.Errorf("qbz2 fallback also failed for artist %s: %w", artistID, err)
}
GoLog("[Qobuz] qbz2 fallback succeeded for artist %s\n", artistID)
return &artist, nil
}
func (q *QobuzDownloader) getPlaylistDetailsPage(playlistID string, limit, offset int) (*qobuzPlaylistDetails, error) { func (q *QobuzDownloader) getPlaylistDetailsPage(playlistID string, limit, offset int) (*qobuzPlaylistDetails, error) {
requestURL := fmt.Sprintf( requestURL := fmt.Sprintf(
"%s%s&extra=tracks&limit=%d&offset=%d&app_id=%s", "%s%s&extra=tracks&limit=%d&offset=%d&app_id=%s",
@@ -895,11 +961,31 @@ func (q *QobuzDownloader) getPlaylistDetailsPage(playlistID string, limit, offse
) )
var playlist qobuzPlaylistDetails var playlist qobuzPlaylistDetails
if err := q.getQobuzJSON(requestURL, &playlist); err != nil { if err := q.getQobuzJSON(requestURL, &playlist); err != nil {
if isQobuzPrimaryUnavailable(err) {
GoLog("[Qobuz] Primary API unavailable for playlist %s, trying qbz2 fallback: %v\n", playlistID, err)
return q.getPlaylistDetailsPageViaMusicDL(playlistID, limit, offset)
}
return nil, err return nil, err
} }
return &playlist, nil return &playlist, nil
} }
func (q *QobuzDownloader) getPlaylistDetailsPageViaMusicDL(playlistID string, limit, offset int) (*qobuzPlaylistDetails, error) {
requestURL := fmt.Sprintf(
"%s%s&limit=%d&offset=%d",
qobuzFallbackPlaylistGetBaseURL,
url.QueryEscape(strings.TrimSpace(playlistID)),
limit,
offset,
)
var playlist qobuzPlaylistDetails
if err := q.getQobuzJSON(requestURL, &playlist); err != nil {
return nil, fmt.Errorf("qbz2 fallback also failed for playlist %s: %w", playlistID, err)
}
GoLog("[Qobuz] qbz2 fallback succeeded for playlist %s (offset=%d)\n", playlistID, offset)
return &playlist, nil
}
func (q *QobuzDownloader) getArtistAlbumIDs(artistID string) ([]string, error) { func (q *QobuzDownloader) getArtistAlbumIDs(artistID string) ([]string, error) {
artist, err := q.getArtistDetails(artistID) artist, err := q.getArtistDetails(artistID)
if err != nil { if err != nil {
@@ -944,6 +1030,7 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
} }
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items)) tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
totalDiscs := 0
for i := range album.Tracks.Items { for i := range album.Tracks.Items {
track := &album.Tracks.Items[i] track := &album.Tracks.Items[i]
track.Album.ID = album.ID track.Album.ID = album.ID
@@ -955,8 +1042,14 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
Large: album.Image.Large, Large: album.Image.Large,
} }
track.Album.TracksCount = album.TracksCount track.Album.TracksCount = album.TracksCount
if track.MediaNumber > totalDiscs {
totalDiscs = track.MediaNumber
}
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track)) tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
} }
for i := range tracks {
tracks[i].TotalDiscs = totalDiscs
}
return &AlbumResponsePayload{ return &AlbumResponsePayload{
AlbumInfo: qobuzAlbumToAlbumInfo(album), AlbumInfo: qobuzAlbumToAlbumInfo(album),
@@ -1062,9 +1155,7 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
return []qobuzAPIProvider{ return []qobuzAPIProvider{
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL}, {Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard}, {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
// "deeb" is mapped from the legacy reference fallback endpoint.
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, {Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
// "qbz" comes from the desktop reference app and uses /api/track/{id}?quality=...
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard}, {Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
{Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard}, {Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard},
} }
@@ -1215,14 +1306,6 @@ func mapQobuzQualityCodeToAPI(qualityCode string) string {
} }
} }
func getQobuzDebugKey() string {
decoded := make([]byte, len(qobuzDebugKeyObfuscated))
for i, b := range qobuzDebugKeyObfuscated {
decoded[i] = b ^ qobuzDebugKeyXORMask
}
return string(decoded)
}
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
candidates, err := q.searchQobuzTracksWithFallback(isrc, 50) candidates, err := q.searchQobuzTracksWithFallback(isrc, 50)
if err != nil { if err != nil {
@@ -1375,9 +1458,10 @@ func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, f
} }
if artistLimit > 0 { if artistLimit > 0 {
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/artist/search?query=%s&limit=%d&app_id=%s", searchURL := fmt.Sprintf("%sartist/search?query=%s&limit=%d&app_id=%s",
url.QueryEscape(cleanQuery), artistLimit, q.appID) qobuzAPIBaseURL, url.QueryEscape(cleanQuery), artistLimit, q.appID)
req, err := http.NewRequest("GET", searchURL, nil) req, err := http.NewRequest("GET", searchURL, nil)
artistSearchDone := false
if err == nil { if err == nil {
resp, reqErr := DoRequestWithUserAgent(q.client, req) resp, reqErr := DoRequestWithUserAgent(q.client, req)
if reqErr == nil { if reqErr == nil {
@@ -1402,20 +1486,30 @@ func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, f
Images: imageURL, Images: imageURL,
}) })
} }
artistSearchDone = true
} else { } else {
GoLog("[Qobuz] Artist search decode failed: %v\n", decErr) GoLog("[Qobuz] Artist search decode failed: %v\n", decErr)
} }
} else if isQobuzPrimaryUnavailable(fmt.Errorf("HTTP %d", resp.StatusCode)) {
GoLog("[Qobuz] Artist search primary API returned HTTP %d, will try qbz2 fallback\n", resp.StatusCode)
} }
} else { } else {
GoLog("[Qobuz] Artist search request failed: %v\n", reqErr) GoLog("[Qobuz] Artist search request failed: %v\n", reqErr)
if isQobuzPrimaryUnavailable(reqErr) {
GoLog("[Qobuz] Primary API unavailable for artist search, will try qbz2 fallback\n")
}
} }
} }
if !artistSearchDone {
q.searchAllArtistsViaMusicDL(cleanQuery, artistLimit, result)
}
} }
if albumLimit > 0 { if albumLimit > 0 {
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s", searchURL := fmt.Sprintf("%salbum/search?query=%s&limit=%d&app_id=%s",
url.QueryEscape(cleanQuery), albumLimit, q.appID) qobuzAPIBaseURL, url.QueryEscape(cleanQuery), albumLimit, q.appID)
req, err := http.NewRequest("GET", searchURL, nil) req, err := http.NewRequest("GET", searchURL, nil)
albumSearchDone := false
if err == nil { if err == nil {
resp, reqErr := DoRequestWithUserAgent(q.client, req) resp, reqErr := DoRequestWithUserAgent(q.client, req)
if reqErr == nil { if reqErr == nil {
@@ -1440,20 +1534,81 @@ func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, f
AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount), AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
}) })
} }
albumSearchDone = true
} else { } else {
GoLog("[Qobuz] Album search decode failed: %v\n", decErr) GoLog("[Qobuz] Album search decode failed: %v\n", decErr)
} }
} else if isQobuzPrimaryUnavailable(fmt.Errorf("HTTP %d", resp.StatusCode)) {
GoLog("[Qobuz] Album search primary API returned HTTP %d, will try qbz2 fallback\n", resp.StatusCode)
} }
} else { } else {
GoLog("[Qobuz] Album search request failed: %v\n", reqErr) GoLog("[Qobuz] Album search request failed: %v\n", reqErr)
if isQobuzPrimaryUnavailable(reqErr) {
GoLog("[Qobuz] Primary API unavailable for album search, will try qbz2 fallback\n")
}
} }
} }
if !albumSearchDone {
q.searchAllAlbumsViaMusicDL(cleanQuery, albumLimit, result)
}
} }
GoLog("[Qobuz] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums)) GoLog("[Qobuz] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
return result, nil return result, nil
} }
func (q *QobuzDownloader) searchAllArtistsViaMusicDL(query string, limit int, result *SearchAllResult) {
requestURL := fmt.Sprintf("%sartist/search?query=%s&limit=%d", qobuzFallbackAPIBaseURL, url.QueryEscape(query), limit)
var searchResp struct {
Artists struct {
Items []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Image qobuzImageSet `json:"image"`
} `json:"items"`
} `json:"artists"`
}
if err := q.getQobuzJSON(requestURL, &searchResp); err != nil {
GoLog("[Qobuz] qbz2 fallback artist search also failed: %v\n", err)
return
}
GoLog("[Qobuz] qbz2 fallback artist search succeeded: %d artists\n", len(searchResp.Artists.Items))
for _, artist := range searchResp.Artists.Items {
imageURL := qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail)
result.Artists = append(result.Artists, SearchArtistResult{
ID: qobuzPrefixedNumericID(artist.ID),
Name: strings.TrimSpace(artist.Name),
Images: imageURL,
})
}
}
func (q *QobuzDownloader) searchAllAlbumsViaMusicDL(query string, limit int, result *SearchAllResult) {
requestURL := fmt.Sprintf("%salbum/search?query=%s&limit=%d", qobuzFallbackAPIBaseURL, url.QueryEscape(query), limit)
var searchResp struct {
Albums struct {
Items []qobuzAlbumDetails `json:"items"`
} `json:"albums"`
}
if err := q.getQobuzJSON(requestURL, &searchResp); err != nil {
GoLog("[Qobuz] qbz2 fallback album search also failed: %v\n", err)
return
}
GoLog("[Qobuz] qbz2 fallback album search succeeded: %d albums\n", len(searchResp.Albums.Items))
for i := range searchResp.Albums.Items {
album := &searchResp.Albums.Items[i]
result.Albums = append(result.Albums, SearchAlbumResult{
ID: qobuzPrefixedID(album.ID),
Name: strings.TrimSpace(album.Title),
Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
Images: qobuzAlbumImage(album),
ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
TotalTracks: album.TracksCount,
AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
})
}
}
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
queries := []string{} queries := []string{}
@@ -1597,21 +1752,27 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
} }
func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string) bool { func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string, skipNameVerification bool) bool {
if track == nil { if track == nil {
return false return false
} }
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { exactISRCMatch := req.ISRC != "" &&
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n", track.ISRC != "" &&
logPrefix, source, req.ArtistName, track.Performer.Name) strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(track.ISRC))
return false
}
if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) { if !exactISRCMatch && !skipNameVerification {
GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n", if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
logPrefix, source, req.TrackName, track.Title) GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
return false logPrefix, source, req.ArtistName, track.Performer.Name)
return false
}
if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) {
GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
logPrefix, source, req.TrackName, track.Title)
return false
}
} }
expectedDurationSec := req.DurationMS / 1000 expectedDurationSec := req.DurationMS / 1000
@@ -1639,12 +1800,22 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo
resp, err := DoRequestWithUserAgent(q.client, req) resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil { if err != nil {
if isQobuzPrimaryUnavailable(err) {
GoLog("[Qobuz] Primary API unavailable for track search, trying qbz2 fallback: %v\n", err)
return q.searchQobuzTracksViaMusicDL(query, limit)
}
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
primaryErr := fmt.Errorf("search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
if isQobuzPrimaryUnavailable(primaryErr) {
GoLog("[Qobuz] Primary API unavailable for track search, trying qbz2 fallback: %v\n", primaryErr)
return q.searchQobuzTracksViaMusicDL(query, limit)
}
return nil, primaryErr
} }
var result struct { var result struct {
@@ -1658,6 +1829,277 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo
return result.Tracks.Items, nil return result.Tracks.Items, nil
} }
func (q *QobuzDownloader) searchQobuzTracksViaMusicDL(query string, limit int) ([]QobuzTrack, error) {
requestURL := fmt.Sprintf("%s%s&limit=%d", qobuzFallbackTrackSearchBaseURL, url.QueryEscape(query), limit)
var result struct {
Tracks struct {
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
if err := q.getQobuzJSON(requestURL, &result); err != nil {
return nil, fmt.Errorf("qbz2 fallback search also failed: %w", err)
}
GoLog("[Qobuz] qbz2 fallback search succeeded: %d tracks for '%s'\n", len(result.Tracks.Items), query)
return result.Tracks.Items, nil
}
type qobuzTrackSearchCandidate struct {
score int
track QobuzTrack
}
func qobuzNormalizedSearchText(value string) string {
return normalizeLooseArtistName(value)
}
func qobuzSearchTokens(value string) []string {
normalized := qobuzNormalizedSearchText(value)
if normalized == "" {
return nil
}
parts := strings.Fields(normalized)
tokens := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
if len(part) < 2 {
continue
}
if _, ok := seen[part]; ok {
continue
}
seen[part] = struct{}{}
tokens = append(tokens, part)
}
return tokens
}
func qobuzScoreTrackSearchCandidate(query string, track *QobuzTrack) int {
if track == nil {
return 0
}
queryNorm := qobuzNormalizedSearchText(query)
if queryNorm == "" {
return 0
}
titleNorm := qobuzNormalizedSearchText(track.Title)
displayNorm := qobuzNormalizedSearchText(qobuzTrackDisplayTitle(track))
artistNorm := qobuzNormalizedSearchText(qobuzTrackArtistName(track))
albumNorm := qobuzNormalizedSearchText(strings.TrimSpace(track.Album.Title))
score := 0
if qobuzTitlesMatch(query, track.Title) || qobuzTitlesMatch(query, qobuzTrackDisplayTitle(track)) {
score += 900
}
switch {
case queryNorm == titleNorm, queryNorm == displayNorm:
score += 1200
case (titleNorm != "" && strings.Contains(titleNorm, queryNorm)) ||
(displayNorm != "" && strings.Contains(displayNorm, queryNorm)):
score += 420
case (titleNorm != "" && strings.Contains(queryNorm, titleNorm)) ||
(displayNorm != "" && strings.Contains(queryNorm, displayNorm)):
score += 260
}
if artistNorm != "" && strings.Contains(queryNorm, artistNorm) {
score += 180
}
if albumNorm != "" && strings.Contains(queryNorm, albumNorm) {
score += 100
}
for _, token := range qobuzSearchTokens(query) {
switch {
case strings.Contains(titleNorm, token), strings.Contains(displayNorm, token):
score += 180
case strings.Contains(artistNorm, token):
score += 70
case strings.Contains(albumNorm, token):
score += 35
}
}
if track.ISRC != "" {
score += 15
}
if track.MaximumBitDepth >= 24 {
score += 10
}
if track.MaximumSamplingRate >= 88.2 {
score += 10
}
return score
}
func selectQobuzTracksFromAlbumSearchResults(
query string,
limit int,
albumSummaries []qobuzAlbumDetails,
loadAlbum func(string) (*qobuzAlbumDetails, error),
) ([]QobuzTrack, error) {
if strings.TrimSpace(query) == "" {
return nil, fmt.Errorf("empty qobuz album-search fallback query")
}
if len(albumSummaries) == 0 {
return nil, fmt.Errorf("album search returned no albums")
}
candidates := make([]qobuzTrackSearchCandidate, 0, limit)
seenTrackIDs := make(map[int64]struct{})
for _, summary := range albumSummaries {
albumID := strings.TrimSpace(summary.ID)
if albumID == "" {
continue
}
album, err := loadAlbum(albumID)
if err != nil || album == nil {
continue
}
for i := range album.Tracks.Items {
track := album.Tracks.Items[i]
track.Album.ID = album.ID
track.Album.QobuzID = album.QobuzID
track.Album.Title = album.Title
track.Album.ReleaseDate = album.ReleaseDateOriginal
track.Album.TracksCount = album.TracksCount
track.Album.ProductType = album.ProductType
track.Album.ReleaseType = album.ReleaseType
track.Album.Artist.ID = album.Artist.ID
track.Album.Artist.Name = album.Artist.Name
track.Album.Artists = album.Artists
track.Album.Image = album.Image
if track.ID > 0 {
if _, ok := seenTrackIDs[track.ID]; ok {
continue
}
seenTrackIDs[track.ID] = struct{}{}
}
score := qobuzScoreTrackSearchCandidate(query, &track)
if score <= 0 {
continue
}
candidates = append(candidates, qobuzTrackSearchCandidate{
score: score,
track: track,
})
}
}
if len(candidates) == 0 {
return nil, fmt.Errorf("album-search fallback returned no scored track candidates")
}
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].score != candidates[j].score {
return candidates[i].score > candidates[j].score
}
if candidates[i].track.MaximumBitDepth != candidates[j].track.MaximumBitDepth {
return candidates[i].track.MaximumBitDepth > candidates[j].track.MaximumBitDepth
}
return candidates[i].track.ID < candidates[j].track.ID
})
if limit > 0 && len(candidates) > limit {
candidates = candidates[:limit]
}
tracks := make([]QobuzTrack, 0, len(candidates))
for _, candidate := range candidates {
tracks = append(tracks, candidate.track)
}
return tracks, nil
}
func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearch(query string, limit int) ([]QobuzTrack, error) {
albumLimit := limit
if albumLimit < 3 {
albumLimit = 3
}
if albumLimit > 8 {
albumLimit = 8
}
searchURL := fmt.Sprintf(
"%salbum/search?query=%s&limit=%d&app_id=%s",
qobuzAPIBaseURL,
url.QueryEscape(strings.TrimSpace(query)),
albumLimit,
q.appID,
)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
if isQobuzPrimaryUnavailable(err) {
GoLog("[Qobuz] Primary API unavailable for album search fallback, trying qbz2: %v\n", err)
return q.searchQobuzTracksViaAlbumSearchMusicDL(query, limit, albumLimit)
}
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
primaryErr := fmt.Errorf("album search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
if isQobuzPrimaryUnavailable(primaryErr) {
GoLog("[Qobuz] Primary API unavailable for album search fallback, trying qbz2: %v\n", primaryErr)
return q.searchQobuzTracksViaAlbumSearchMusicDL(query, limit, albumLimit)
}
return nil, primaryErr
}
var albumResp struct {
Albums struct {
Items []qobuzAlbumDetails `json:"items"`
} `json:"albums"`
}
if err := json.NewDecoder(resp.Body).Decode(&albumResp); err != nil {
return nil, err
}
return selectQobuzTracksFromAlbumSearchResults(
query,
limit,
albumResp.Albums.Items,
q.getAlbumDetails,
)
}
func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearchMusicDL(query string, limit, albumLimit int) ([]QobuzTrack, error) {
requestURL := fmt.Sprintf("%salbum/search?query=%s&limit=%d", qobuzFallbackAPIBaseURL, url.QueryEscape(strings.TrimSpace(query)), albumLimit)
var searchResp struct {
Albums struct {
Items []qobuzAlbumDetails `json:"items"`
} `json:"albums"`
}
if err := q.getQobuzJSON(requestURL, &searchResp); err != nil {
return nil, fmt.Errorf("qbz2 fallback album search also failed: %w", err)
}
GoLog("[Qobuz] qbz2 fallback album search returned %d albums\n", len(searchResp.Albums.Items))
return selectQobuzTracksFromAlbumSearchResults(
query,
limit,
searchResp.Albums.Items,
q.getAlbumDetails,
)
}
func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 { func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 {
matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1) matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1)
if len(matches) == 0 { if len(matches) == 0 {
@@ -1735,9 +2177,18 @@ func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int)
if len(apiTracks) > 0 { if len(apiTracks) > 0 {
return apiTracks, nil return apiTracks, nil
} }
GoLog("[Qobuz] API search returned 0 results for '%s', trying store fallback\n", query) GoLog("[Qobuz] API search returned 0 results for '%s', trying album-search fallback\n", query)
} else { } else {
GoLog("[Qobuz] API search failed for '%s': %v. Trying store fallback.\n", query, apiErr) GoLog("[Qobuz] API search failed for '%s': %v. Trying album-search fallback.\n", query, apiErr)
}
albumTracks, albumErr := q.searchQobuzTracksViaAlbumSearch(query, limit)
if albumErr == nil && len(albumTracks) > 0 {
GoLog("[Qobuz] Album-search fallback returned %d candidate tracks for '%s'\n", len(albumTracks), query)
return albumTracks, nil
}
if albumErr != nil {
GoLog("[Qobuz] Album-search fallback failed for '%s': %v. Trying store fallback.\n", query, albumErr)
} }
storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit) storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit)
@@ -1746,10 +2197,21 @@ func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int)
return storeTracks, nil return storeTracks, nil
} }
if apiErr != nil && storeErr != nil { if apiErr != nil && albumErr != nil && storeErr != nil {
return nil, fmt.Errorf("api search failed (%v); store fallback failed (%v)", apiErr, storeErr) return nil, fmt.Errorf(
"api search failed (%v); album-search fallback failed (%v); store fallback failed (%v)",
apiErr,
albumErr,
storeErr,
)
}
if albumErr == nil && len(albumTracks) == 0 && storeErr != nil {
return nil, storeErr
} }
if storeErr != nil { if storeErr != nil {
if albumErr != nil {
return nil, albumErr
}
return nil, storeErr return nil, storeErr
} }
return nil, fmt.Errorf("no tracks found for query: %s", query) return nil, fmt.Errorf("no tracks found for query: %s", query)
@@ -2125,7 +2587,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err) GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err)
track = nil track = nil
} else if track != nil { } else if track != nil {
if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID") { if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID", false) {
GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
} else { } else {
track = nil track = nil
@@ -2142,7 +2604,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if err != nil { if err != nil {
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err) GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
track = nil track = nil
} else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID") { } else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID", false) {
track = nil track = nil
} }
} }
@@ -2162,7 +2624,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err) GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err)
track = nil track = nil
} else if track != nil { } else if track != nil {
if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID") { if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID", true) {
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
if req.ISRC != "" { if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID) GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
@@ -2179,7 +2641,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC) GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec) track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search") { if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search", false) {
track = nil track = nil
} }
} }
@@ -2188,7 +2650,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil { if track == nil {
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName) GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec) track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search") { if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search", false) {
track = nil track = nil
} }
} }
@@ -2253,7 +2715,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
qobuzQuality = "6" qobuzQuality = "6"
case "HI_RES": case "HI_RES":
qobuzQuality = "7" qobuzQuality = "7"
case "HI_RES_LOSSLESS": case "HI_RES_LOSSLESS", "", "DEFAULT":
qobuzQuality = "27" qobuzQuality = "27"
} }
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
@@ -2329,18 +2791,21 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
} }
metadata := Metadata{ metadata := Metadata{
Title: track.Title, Title: track.Title,
Artist: track.Performer.Name, Artist: req.ArtistName,
Album: albumName, Album: albumName,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
Date: releaseDate, ArtistTagMode: req.ArtistTagMode,
TrackNumber: actualTrackNumber, Date: releaseDate,
TotalTracks: req.TotalTracks, TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber, TotalTracks: req.TotalTracks,
ISRC: track.ISRC, DiscNumber: req.DiscNumber,
Genre: req.Genre, TotalDiscs: req.TotalDiscs,
Label: req.Label, ISRC: track.ISRC,
Copyright: req.Copyright, Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Composer: req.Composer,
} }
var coverData []byte var coverData []byte
@@ -2405,6 +2870,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
req.DiscNumber, req.DiscNumber,
) )
// Prefer the cover URL the frontend sent (user-selected album) over the
// track's default album cover returned by the Qobuz track/get API, which
// may belong to a different album when the same track appears on multiple
// releases.
resultCoverURL := strings.TrimSpace(req.CoverURL)
if resultCoverURL == "" {
resultCoverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
}
return QobuzDownloadResult{ return QobuzDownloadResult{
FilePath: outputPath, FilePath: outputPath,
BitDepth: actualBitDepth, BitDepth: actualBitDepth,
@@ -2416,7 +2890,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
TrackNumber: resultTrackNumber, TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber, DiscNumber: resultDiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)), CoverURL: resultCoverURL,
LyricsLRC: lyricsLRC, LyricsLRC: lyricsLRC,
}, nil }, nil
} }
+94 -12
View File
@@ -5,6 +5,21 @@ import (
"testing" "testing"
) )
func buildTestQobuzAlbum(id, title, artist string, tracks ...QobuzTrack) *qobuzAlbumDetails {
album := &qobuzAlbumDetails{
ID: id,
Title: title,
ReleaseDateOriginal: "2013-05-20",
TracksCount: len(tracks),
ProductType: "album",
ReleaseType: "album",
}
album.Artist = qobuzArtistRef{ID: 1, Name: artist}
album.Artists = []qobuzArtistRef{{ID: 1, Name: artist}}
album.Tracks.Items = tracks
return album
}
func TestParseQobuzURL(t *testing.T) { func TestParseQobuzURL(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -186,18 +201,6 @@ func TestNormalizeQobuzQualityCode(t *testing.T) {
} }
} }
func TestGetQobuzDebugKey(t *testing.T) {
got := getQobuzDebugKey()
if len(got) != len(qobuzDebugKeyObfuscated) {
t.Fatalf("unexpected debug key length: %d", len(got))
}
for i := range got {
if got[i]^qobuzDebugKeyXORMask != qobuzDebugKeyObfuscated[i] {
t.Fatalf("unexpected debug key reconstruction at index %d", i)
}
}
}
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) { func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7") payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
if err != nil { if err != nil {
@@ -276,6 +279,68 @@ func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
return track return track
} }
func TestSelectQobuzTracksFromAlbumSearchResultsPrefersMatchingTrack(t *testing.T) {
summaries := []qobuzAlbumDetails{
{ID: "album-a"},
{ID: "album-b"},
}
match := *testQobuzTrack(1, "Get Lucky", "Daft Punk", 369)
other := *testQobuzTrack(2, "Fragments of Time", "Daft Punk", 280)
fallback := *testQobuzTrack(3, "Da Funk", "Daft Punk", 330)
albums := map[string]*qobuzAlbumDetails{
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", match, other),
"album-b": buildTestQobuzAlbum("album-b", "Homework", "Daft Punk", fallback),
}
tracks, err := selectQobuzTracksFromAlbumSearchResults(
"daft punk get lucky",
3,
summaries,
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tracks) == 0 {
t.Fatal("expected tracks, got none")
}
if tracks[0].ID != 1 {
t.Fatalf("expected Get Lucky to rank first, got track id %d", tracks[0].ID)
}
}
func TestSelectQobuzTracksFromAlbumSearchResultsDedupesTracks(t *testing.T) {
summaries := []qobuzAlbumDetails{
{ID: "album-a"},
{ID: "album-b"},
}
shared := *testQobuzTrack(42, "Get Lucky", "Daft Punk", 369)
albums := map[string]*qobuzAlbumDetails{
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", shared),
"album-b": buildTestQobuzAlbum("album-b", "Random Access Memories Deluxe", "Daft Punk", shared),
}
tracks, err := selectQobuzTracksFromAlbumSearchResults(
"daft punk get lucky",
5,
summaries,
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("expected 1 deduped track, got %d", len(tracks))
}
if tracks[0].ID != 42 {
t.Fatalf("unexpected deduped track id: %d", tracks[0].ID)
}
}
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) { func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
origGetTrackByID := qobuzGetTrackByIDFunc origGetTrackByID := qobuzGetTrackByIDFunc
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
@@ -436,3 +501,20 @@ func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testin
t.Fatalf("unexpected resolved track: %+v", track) t.Fatalf("unexpected resolved track: %+v", track)
} }
} }
func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
req := DownloadRequest{
TrackName: "Ringišpil",
ArtistName: "Djordje Balasevic",
}
track := &QobuzTrack{
Title: "Different Title",
Duration: 0,
}
track.Performer.Name = "Different Artist"
if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) {
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
}
}
+4 -17
View File
@@ -16,16 +16,13 @@ var hiraganaToRomaji = map[rune]string{
'や': "ya", 'ゆ': "yu", 'よ': "yo", 'や': "ya", 'ゆ': "yu", 'よ': "yo",
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro", 'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
'わ': "wa", 'を': "wo", 'ん': "n", 'わ': "wa", 'を': "wo", 'ん': "n",
// Dakuten (voiced)
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go", 'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo", 'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do", 'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo", 'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
// Handakuten (semi-voiced)
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po", 'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
// Small characters
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo", 'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
'っ': "", // Double consonant marker 'っ': "",
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o", 'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
} }
@@ -40,19 +37,15 @@ var katakanaToRomaji = map[rune]string{
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo", 'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro", 'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
'ワ': "wa", 'ヲ': "wo", 'ン': "n", 'ワ': "wa", 'ヲ': "wo", 'ン': "n",
// Dakuten (voiced)
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go", 'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo", 'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do", 'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo", 'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
// Handakuten (semi-voiced)
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po", 'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
// Small characters
'ャ': "ya", 'ュ': "yu", 'ョ': "yo", 'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
'ッ': "", // Double consonant marker 'ッ': "",
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o", 'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
// Extended katakana 'ー': "",
'ー': "", // Long vowel mark
'ヴ': "vu", 'ヴ': "vu",
} }
@@ -82,7 +75,6 @@ var combinationKatakana = map[string]string{
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo", "ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo", "ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo", "ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
// Extended combinations
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du", "ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo", "ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
"ウィ": "wi", "ウェ": "we", "ウォ": "wo", "ウィ": "wi", "ウェ": "we", "ウォ": "wo",
@@ -120,7 +112,6 @@ func JapaneseToRomaji(text string) string {
i := 0 i := 0
for i < len(runes) { for i < len(runes) {
// Check for っ/ッ (double consonant)
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') { if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
nextRomaji := "" nextRomaji := ""
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok { if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
@@ -129,13 +120,12 @@ func JapaneseToRomaji(text string) string {
nextRomaji = romaji nextRomaji = romaji
} }
if len(nextRomaji) > 0 { if len(nextRomaji) > 0 {
result.WriteByte(nextRomaji[0]) // Double the first consonant result.WriteByte(nextRomaji[0])
} }
i++ i++
continue continue
} }
// Check for two-character combinations
if i < len(runes)-1 { if i < len(runes)-1 {
combo := string(runes[i : i+2]) combo := string(runes[i : i+2])
if romaji, ok := combinationHiragana[combo]; ok { if romaji, ok := combinationHiragana[combo]; ok {
@@ -150,17 +140,14 @@ func JapaneseToRomaji(text string) string {
} }
} }
// Single character conversion
r := runes[i] r := runes[i]
if romaji, ok := hiraganaToRomaji[r]; ok { if romaji, ok := hiraganaToRomaji[r]; ok {
result.WriteString(romaji) result.WriteString(romaji)
} else if romaji, ok := katakanaToRomaji[r]; ok { } else if romaji, ok := katakanaToRomaji[r]; ok {
result.WriteString(romaji) result.WriteString(romaji)
} else if isKanji(r) { } else if isKanji(r) {
// Keep kanji as-is (would need dictionary for proper conversion)
result.WriteRune(r) result.WriteRune(r)
} else { } else {
// Keep other characters (punctuation, spaces, etc.)
result.WriteRune(r) result.WriteRune(r)
} }
i++ i++
+212 -444
View File
@@ -87,38 +87,210 @@ func GetSongLinkRegion() string {
return region return region
} }
const resolveAPIURL = "https://api.zarz.moe/v1/resolve"
func songLinkBaseURL() string { func songLinkBaseURL() string {
opts := GetNetworkCompatibilityOptions()
if opts.AllowHTTP {
return "http://api.song.link/v1-alpha.1/links"
}
return "https://api.song.link/v1-alpha.1/links" return "https://api.song.link/v1-alpha.1/links"
} }
func buildSongLinkURLFromTarget(targetURL string, userCountry string) string { // resolveTrackPlatforms resolves a music URL to all platforms.
if userCountry == "" { // Spotify URLs use the resolve API; if that fails, falls back to SongLink.
userCountry = GetSongLinkRegion() // All other URLs go directly to SongLink.
func (s *SongLinkClient) resolveTrackPlatforms(inputURL string) (map[string]songLinkPlatformLink, error) {
if isSpotifyURL(inputURL) {
payload, err := json.Marshal(map[string]string{"url": inputURL})
if err != nil {
return nil, fmt.Errorf("failed to encode resolve request: %w", err)
}
links, err := s.doResolveRequest(payload)
if err == nil {
return links, nil
}
GoLog("[SongLink] Resolve proxy failed for %s: %v, falling back to SongLink", inputURL, err)
return s.songLinkByTargetURL(inputURL)
} }
apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL)) return s.songLinkByTargetURL(inputURL)
if userCountry != "" {
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
}
return apiURL
} }
func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string { // resolveTrackPlatformsByPlatform resolves using platform + type + id.
if userCountry == "" { // Spotify uses the resolve API with SongLink fallback; all other platforms use SongLink directly.
userCountry = GetSongLinkRegion() func (s *SongLinkClient) resolveTrackPlatformsByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
if strings.EqualFold(platform, "spotify") {
payload, err := json.Marshal(map[string]string{
"platform": platform,
"type": entityType,
"id": entityID,
})
if err != nil {
return nil, fmt.Errorf("failed to encode resolve request: %w", err)
}
links, err := s.doResolveRequest(payload)
if err == nil {
return links, nil
}
GoLog("[SongLink] Resolve proxy failed for %s/%s/%s: %v, falling back to SongLink", platform, entityType, entityID, err)
return s.songLinkByPlatform(platform, entityType, entityID)
} }
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s", return s.songLinkByPlatform(platform, entityType, entityID)
}
func isSpotifyURL(u string) bool {
lower := strings.ToLower(u)
return strings.Contains(lower, "spotify.com/") || strings.Contains(lower, "spotify:")
}
// doResolveRequest sends a JSON payload to the resolve API (api.zarz.moe)
// and parses the response into a platform link map.
func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPlatformLink, error) {
req, err := http.NewRequest("POST", resolveAPIURL, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("failed to create resolve request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("resolve API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("resolve API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read resolve response: %w", err)
}
var resolveResp struct {
Success bool `json:"success"`
ISRC string `json:"isrc"`
SongUrls map[string]json.RawMessage `json:"songUrls"`
}
if err := json.Unmarshal(body, &resolveResp); err != nil {
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
}
if !resolveResp.Success {
return nil, fmt.Errorf("resolve API returned success=false")
}
keyMap := map[string]string{
"Spotify": "spotify",
"Deezer": "deezer",
"Tidal": "tidal",
"YouTubeMusic": "youtubeMusic",
"YouTube": "youtube",
"AmazonMusic": "amazonMusic",
"Qobuz": "qobuz",
"AppleMusic": "appleMusic",
}
links := make(map[string]songLinkPlatformLink)
for resolveKey, platformKey := range keyMap {
rawValue, ok := resolveResp.SongUrls[resolveKey]
if !ok {
continue
}
if u := extractResolveURLValue(rawValue); u != "" {
links[platformKey] = songLinkPlatformLink{URL: u}
}
}
if len(links) == 0 {
return nil, fmt.Errorf("resolve API returned no platform links")
}
return links, nil
}
func extractResolveURLValue(raw json.RawMessage) string {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return ""
}
var direct string
if err := json.Unmarshal(trimmed, &direct); err == nil {
return strings.TrimSpace(direct)
}
var list []string
if err := json.Unmarshal(trimmed, &list); err == nil {
for _, candidate := range list {
if cleaned := strings.TrimSpace(candidate); cleaned != "" {
return cleaned
}
}
}
return ""
}
// songLinkByTargetURL calls the SongLink API with a target URL (for non-Spotify URLs).
func (s *SongLinkClient) songLinkByTargetURL(targetURL string) (map[string]songLinkPlatformLink, error) {
songLinkRateLimiter.WaitForSlot()
apiURL := fmt.Sprintf("%s?url=%s&userCountry=%s",
songLinkBaseURL(),
url.QueryEscape(targetURL),
url.QueryEscape(GetSongLinkRegion()))
return s.doSongLinkRequest(apiURL)
}
// songLinkByPlatform calls the SongLink API with platform + type + id (for non-Spotify platforms).
func (s *SongLinkClient) songLinkByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
songLinkRateLimiter.WaitForSlot()
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s&userCountry=%s",
songLinkBaseURL(), songLinkBaseURL(),
url.QueryEscape(platform), url.QueryEscape(platform),
url.QueryEscape(entityType), url.QueryEscape(entityType),
url.QueryEscape(entityID)) url.QueryEscape(entityID),
if userCountry != "" { url.QueryEscape(GetSongLinkRegion()))
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
return s.doSongLinkRequest(apiURL)
}
// doSongLinkRequest calls the SongLink API and parses the response.
func (s *SongLinkClient) doSongLinkRequest(apiURL string) (map[string]songLinkPlatformLink, error) {
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create SongLink request: %w", err)
} }
return apiURL
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("SongLink request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 429 {
return nil, fmt.Errorf("SongLink rate limit exceeded")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("SongLink returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read SongLink response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode SongLink response: %w", err)
}
if len(songLinkResp.LinksByPlatform) == 0 {
return nil, fmt.Errorf("SongLink returned no platform links")
}
return songLinkResp.LinksByPlatform, nil
} }
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
@@ -136,145 +308,12 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
} }
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) { func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
if pageErr == nil {
return availability, nil
}
if !songLinkRateLimiter.TryAcquire() {
return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
}
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
apiURL := buildSongLinkURLFromTarget(spotifyURL, "") links, err := s.resolveTrackPlatforms(spotifyURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("resolve proxy failed for Spotify %s: %w", spotifyTrackID, err)
} }
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, links), nil
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
}
defer resp.Body.Close()
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
}
if resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on any streaming platform")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
}
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
req, err := http.NewRequest("GET", pageURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
}
req.Header.Set("Accept", "text/html,application/xhtml+xml")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on song.link page")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read song.link page: %w", err)
}
nextDataJSON, err := extractSongLinkNextDataJSON(body)
if err != nil {
return nil, err
}
var pageData struct {
Props struct {
PageProps struct {
PageData struct {
Sections []struct {
Links []struct {
Platform string `json:"platform"`
URL string `json:"url"`
Show bool `json:"show"`
} `json:"links"`
} `json:"sections"`
} `json:"pageData"`
} `json:"pageProps"`
} `json:"props"`
}
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
}
linksByPlatform := make(map[string]songLinkPlatformLink)
for _, section := range pageData.Props.PageProps.PageData.Sections {
for _, link := range section.Links {
if !link.Show || strings.TrimSpace(link.URL) == "" {
continue
}
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
}
}
if len(linksByPlatform) == 0 {
return nil, fmt.Errorf("song.link page contained no usable platform links")
}
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
}
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
const endMarker = `</script>`
start := bytes.Index(body, []byte(startMarker))
if start < 0 {
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
}
start += len(startMarker)
end := bytes.Index(body[start:], []byte(endMarker))
if end < 0 {
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
}
return body[start : start+end], nil
} }
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) { func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
@@ -469,8 +508,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
return "" return ""
} }
// isNumeric is defined in library_scan.go
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "") availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil { if err != nil {
@@ -505,47 +542,17 @@ type AlbumAvailability struct {
} }
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
songLinkRateLimiter.WaitForSlot()
spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID) spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID)
apiURL := buildSongLinkURLFromTarget(spotifyURL, "") links, err := s.resolveTrackPlatforms(spotifyURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("resolve proxy failed for album %s: %w", spotifyAlbumID, err)
}
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check album availability: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
} }
availability := &AlbumAvailability{ availability := &AlbumAvailability{
SpotifyID: spotifyAlbumID, SpotifyID: spotifyAlbumID,
} }
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true availability.Deezer = true
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
@@ -588,101 +595,19 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
} }
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) { func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
apiURL := buildSongLinkURLFromTarget(deezerURL, "") links, err := s.resolveTrackPlatforms(deezerURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("resolve failed for Deezer %s: %w", deezerTrackID, err)
} }
retryConfig := songLinkRetryConfig() availability := buildTrackAvailabilityFromSongLinkLinks("", links)
resp, err := DoRequestWithRetry(s.client, req, retryConfig) // Ensure Deezer is always marked available since we started from a Deezer URL
if err != nil { availability.Deezer = true
return nil, fmt.Errorf("failed to check availability: %w", err) availability.DeezerID = deezerTrackID
if availability.DeezerURL == "" {
availability.DeezerURL = deezerURL
} }
defer resp.Body.Close()
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
}
if resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on any streaming platform")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("SongLink rate limit exceeded")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
EntitiesByUniqueId map[string]struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
ArtistName string `json:"artistName"`
} `json:"entitiesByUniqueId"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
availability := &TrackAvailability{
Deezer: true,
DeezerID: deezerTrackID,
}
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.DeezerURL = deezerLink.URL
}
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
if !availability.YouTube {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -694,94 +619,12 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
return nil, fmt.Errorf("%s ID is empty", platform) return nil, fmt.Errorf("%s ID is empty", platform)
} }
songLinkRateLimiter.WaitForSlot() links, err := s.resolveTrackPlatformsByPlatform(platform, entityType, entityID)
apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "")
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("resolve failed for %s %s: %w", platform, entityID, err)
} }
retryConfig := songLinkRetryConfig() return buildTrackAvailabilityFromSongLinkLinks("", links), nil
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
}
if resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on any streaming platform")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("SongLink rate limit exceeded")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
availability := &TrackAvailability{}
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
if !availability.YouTube {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability, nil
} }
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability { func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
@@ -894,85 +737,10 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string,
} }
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot() links, err := s.resolveTrackPlatforms(inputURL)
apiURL := buildSongLinkURLFromTarget(inputURL, "")
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("resolve failed for URL %s: %w", inputURL, err)
} }
retryConfig := songLinkRetryConfig() return buildTrackAvailabilityFromSongLinkLinks("", links), nil
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 400 || resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on SongLink")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("SongLink rate limit exceeded")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
EntityID string `json:"entityUniqueId"`
} `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
availability := &TrackAvailability{}
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
if !availability.YouTube {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability, nil
} }
+108 -36
View File
@@ -23,26 +23,24 @@ func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
} }
} }
func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) { func TestCheckTrackAvailabilityFromSpotifyViaResolveAPI(t *testing.T) {
origRetryConfig := songLinkRetryConfig
defer func() { songLinkRetryConfig = origRetryConfig }()
client := &SongLinkClient{ client := &SongLinkClient{
client: &http.Client{ client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch { if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" {
case req.URL.Host == "api.song.link": body := `{"success":true,"isrc":"USRC12345678","songUrls":{"Spotify":"https://open.spotify.com/track/testspotifyid","Deezer":"https://www.deezer.com/track/908604612","AmazonMusic":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","Tidal":"https://listen.tidal.com/track/134858527","Qobuz":"https://open.qobuz.com/track/195125822","YouTubeMusic":"https://music.youtube.com/watch?v=testvideoid1"}}`
t.Fatalf("api.song.link should not be called when song.link page succeeds")
return nil, nil
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
body := `<!DOCTYPE html><html><body><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"pageData":{"sections":[{"displayName":"Listen","links":[{"platform":"spotify","url":"https://open.spotify.com/track/testspotifyid","show":true},{"platform":"deezer","url":"https://www.deezer.com/track/908604612","show":true},{"platform":"amazonMusic","url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","show":true},{"platform":"tidal","url":"https://listen.tidal.com/track/134858527","show":true},{"platform":"qobuz","url":"https://open.qobuz.com/track/195125822","show":true},{"platform":"youtubeMusic","url":"https://music.youtube.com/watch?v=testvideoid1","show":true}]}]}}}}</script></body></html>`
return &http.Response{ return &http.Response{
StatusCode: 200, StatusCode: 200,
Header: make(http.Header), Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)), Body: io.NopCloser(strings.NewReader(body)),
Request: req, Request: req,
}, nil }, nil
default:
t.Fatalf("unexpected request: %s", req.URL.String())
return nil, nil
} }
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
return nil, nil
}), }),
}, },
} }
@@ -66,62 +64,136 @@ func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
} }
} }
func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) { func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) {
origRetryConfig := songLinkRetryConfig origRetryConfig := songLinkRetryConfig
songLinkRetryConfig = func() RetryConfig { songLinkRetryConfig = func() RetryConfig {
return RetryConfig{ return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
MaxRetries: 0,
InitialDelay: 0,
MaxDelay: 0,
BackoffFactor: 1,
}
} }
defer func() { defer func() { songLinkRetryConfig = origRetryConfig }()
songLinkRetryConfig = origRetryConfig
}() var hitSongLink bool
client := &SongLinkClient{ client := &SongLinkClient{
client: &http.Client{ client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch { // Resolve proxy returns 500
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid": if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" {
return &http.Response{ return &http.Response{
StatusCode: 500, StatusCode: 500,
Header: make(http.Header), Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("page failure")), Body: io.NopCloser(strings.NewReader("internal error")),
Request: req, Request: req,
}, nil }, nil
case req.URL.Host == "api.song.link": }
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}` // SongLink fallback should be called
if req.URL.Host == "api.song.link" {
hitSongLink = true
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"}}}`
return &http.Response{ return &http.Response{
StatusCode: 200, StatusCode: 200,
Header: make(http.Header), Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)), Body: io.NopCloser(strings.NewReader(body)),
Request: req, Request: req,
}, nil }, nil
default:
t.Fatalf("unexpected request: %s", req.URL.String())
return nil, nil
} }
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
return nil, nil
}), }),
}, },
} }
availability, err := client.CheckTrackAvailability("testspotifyid", "") availability, err := client.CheckTrackAvailability("testspotifyid", "")
if err != nil {
t.Fatalf("expected SongLink fallback to succeed, got error: %v", err)
}
if !hitSongLink {
t.Fatal("expected fallback request to SongLink API, but it was never called")
}
if !availability.Deezer || availability.DeezerID != "908604612" {
t.Fatalf("Deezer availability via fallback = %+v, want DeezerID 908604612", availability)
}
}
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPIMixedSongURLShapes(t *testing.T) {
origRetryConfig := songLinkRetryConfig
defer func() { songLinkRetryConfig = origRetryConfig }()
client := &SongLinkClient{
client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" {
body := `{"success":true,"isrc":"TCAHA2367688","songUrls":{"Spotify":"https://open.spotify.com/track/5glgyj6zH0irbNGfukHacv","Deezer":"https://www.deezer.com/track/2248583177","Tidal":"https://tidal.com/browse/track/290565315","AppleMusic":"https://geo.music.apple.com/us/album/example?i=1","YouTubeMusic":null,"YouTube":"https://www.youtube.com/watch?v=wD_e59XUNdQ","AmazonMusic":"https://music.amazon.com/tracks/B0C35TG38Y/?ref=dm_ff_amazonmusic_3p","Beatport":null,"BeatSource":null,"SoundCloud":null,"Qobuz":null,"Other":[]}}`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
}
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
return nil, nil
}),
},
}
availability, err := client.CheckTrackAvailability("5glgyj6zH0irbNGfukHacv", "")
if err != nil { if err != nil {
t.Fatalf("CheckTrackAvailability() error = %v", err) t.Fatalf("CheckTrackAvailability() error = %v", err)
} }
if availability.SpotifyID != "testspotifyid" { if availability.SpotifyID != "5glgyj6zH0irbNGfukHacv" {
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid") t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "5glgyj6zH0irbNGfukHacv")
} }
if !availability.Deezer || availability.DeezerID != "908604612" { if !availability.Deezer || availability.DeezerID != "2248583177" {
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability) t.Fatalf("Deezer availability = %+v, want DeezerID 2248583177", availability)
} }
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube { if !availability.Tidal || availability.TidalID != "290565315" {
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability) t.Fatalf("Tidal availability = %+v, want TidalID 290565315", availability)
} }
if availability.YouTubeID != "testvideoid1" { if availability.Qobuz {
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1") t.Fatalf("Qobuz should remain false when resolve response contains null, got %+v", availability)
}
}
func TestCheckAvailabilityFromDeezerUsesSongLink(t *testing.T) {
origRetryConfig := songLinkRetryConfig
songLinkRetryConfig = func() RetryConfig {
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
}
defer func() { songLinkRetryConfig = origRetryConfig }()
client := &SongLinkClient{
client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
// Non-Spotify should go to SongLink, not resolve API
if req.URL.Host == "api.zarz.moe" {
t.Fatalf("non-Spotify URL should not hit resolve API, got: %s", req.URL.String())
return nil, nil
}
if req.URL.Host == "api.song.link" {
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvid"}}}`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
}
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
return nil, nil
}),
},
}
availability, err := client.checkAvailabilityFromDeezerSongLink("908604612")
if err != nil {
t.Fatalf("checkAvailabilityFromDeezerSongLink() error = %v", err)
}
if !availability.Deezer || availability.DeezerID != "908604612" {
t.Fatalf("Deezer = %+v, want DeezerID 908604612", availability)
}
if availability.SpotifyID != "testid" {
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testid")
} }
} }
-80
View File
@@ -1,80 +0,0 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/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)
}
}
File diff suppressed because it is too large Load Diff
+47 -35
View File
@@ -829,6 +829,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, a
resolved := resolvedTrackInfo{ resolved := resolvedTrackInfo{
Title: strings.TrimSpace(track.Title), Title: strings.TrimSpace(track.Title),
ArtistName: tidalTrackArtistsDisplay(track), ArtistName: tidalTrackArtistsDisplay(track),
ISRC: strings.TrimSpace(track.ISRC),
Duration: track.Duration, Duration: track.Duration,
} }
if trackMatchesRequest(req, resolved, "Tidal search") { if trackMatchesRequest(req, resolved, "Tidal search") {
@@ -874,8 +875,6 @@ func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad
return results, nil return results, nil
} }
// SearchAll searches Tidal for tracks, artists, and albums matching the query.
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) { func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter) GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
@@ -1013,6 +1012,7 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
} }
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items)) tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
totalDiscs := 0
for _, item := range itemsModule.PagedList.Items { for _, item := range itemsModule.PagedList.Items {
track := item.Item track := item.Item
track.Album.ID = headerModule.Album.ID track.Album.ID = headerModule.Album.ID
@@ -1020,8 +1020,14 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
track.Album.Cover = headerModule.Album.Cover track.Album.Cover = headerModule.Album.Cover
track.Album.ReleaseDate = headerModule.Album.ReleaseDate track.Album.ReleaseDate = headerModule.Album.ReleaseDate
track.Album.URL = headerModule.Album.URL track.Album.URL = headerModule.Album.URL
if track.VolumeNumber > totalDiscs {
totalDiscs = track.VolumeNumber
}
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track)) tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
} }
for i := range tracks {
tracks[i].TotalDiscs = totalDiscs
}
return &AlbumResponsePayload{ return &AlbumResponsePayload{
AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album), AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album),
@@ -1164,7 +1170,6 @@ type tidalAPIResult struct {
duration time.Duration duration time.Duration
} }
// Mobile networks are more unstable, so we use longer timeouts
const ( const (
tidalAPITimeoutMobile = 25 * time.Second tidalAPITimeoutMobile = 25 * time.Second
tidalMaxRetries = 2 tidalMaxRetries = 2
@@ -1210,7 +1215,6 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
continue continue
} }
// 429 rate limit - wait and retry
if resp.StatusCode == 429 { if resp.StatusCode == 429 {
io.Copy(io.Discard, resp.Body) io.Copy(io.Discard, resp.Body)
resp.Body.Close() resp.Body.Close()
@@ -1232,7 +1236,6 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
continue continue
} }
// Try V2 response format (with manifest)
var v2Response TidalAPIResponseV2 var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
if v2Response.Data.AssetPresentation == "PREVIEW" { if v2Response.Data.AssetPresentation == "PREVIEW" {
@@ -1246,7 +1249,6 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
}, nil }, nil
} }
// Try V1 response format
var v1Responses []struct { var v1Responses []struct {
OriginalTrackURL string `json:"OriginalTrackUrl"` OriginalTrackURL string `json:"OriginalTrackUrl"`
} }
@@ -1601,10 +1603,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return nil return nil
} }
// For DASH format, determine correct M4A path
// If outputPath already ends with .m4a, use it directly.
// If outputPath ends with .flac, convert .flac to .m4a.
// Otherwise (e.g., SAF /proc/self/fd/*), use outputPath as-is.
var m4aPath string var m4aPath string
if strings.HasSuffix(outputPath, ".m4a") { if strings.HasSuffix(outputPath, ".m4a") {
m4aPath = outputPath m4aPath = outputPath
@@ -1878,8 +1876,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
} }
} }
// Emoji/symbol-only titles must be matched strictly to avoid false positives
// like mapping "🪐" to "Higher Power".
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) && if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
strings.TrimSpace(expectedTitle) != "" && strings.TrimSpace(expectedTitle) != "" &&
strings.TrimSpace(foundTitle) != "" { strings.TrimSpace(foundTitle) != "" {
@@ -2035,6 +2031,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
expectedDurationSec := req.DurationMS / 1000 expectedDurationSec := req.DurationMS / 1000
var trackID int64 var trackID int64
var gotTidalID bool var gotTidalID bool
var resolvedViaSongLink bool
if req.TidalID != "" { if req.TidalID != "" {
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID) GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
@@ -2094,6 +2091,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
trackID = parsedTrackID trackID = parsedTrackID
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID) GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
gotTidalID = true gotTidalID = true
resolvedViaSongLink = true
return return
} }
} }
@@ -2103,11 +2101,11 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
if idErr == nil && trackID > 0 { if idErr == nil && trackID > 0 {
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID) GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
gotTidalID = true gotTidalID = true
resolvedViaSongLink = true
} }
} }
} }
// Prefer Deezer-based SongLink lookup when DeezerID is available.
if req.DeezerID != "" { if req.DeezerID != "" {
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, req.DeezerID) GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, req.DeezerID)
songlink := NewSongLinkClient() songlink := NewSongLinkClient()
@@ -2146,23 +2144,22 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink") return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
} }
// Verify the resolved track matches the request.
actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10)) actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10))
if fetchErr != nil { if fetchErr != nil {
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr) GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
// Continue without verification — better than failing entirely.
} else { } else {
providerArtist := actualTrack.Artist.Name providerArtist := actualTrack.Artist.Name
if providerArtist == "" && len(actualTrack.Artists) > 0 { if providerArtist == "" && len(actualTrack.Artists) > 0 {
providerArtist = actualTrack.Artists[0].Name providerArtist = actualTrack.Artists[0].Name
} }
resolved := resolvedTrackInfo{ resolved := resolvedTrackInfo{
Title: actualTrack.Title, Title: actualTrack.Title,
ArtistName: providerArtist, ArtistName: providerArtist,
Duration: actualTrack.Duration, ISRC: strings.TrimSpace(actualTrack.ISRC),
Duration: actualTrack.Duration,
SkipNameVerification: resolvedViaSongLink,
} }
if !trackMatchesRequest(req, resolved, logPrefix) { if !trackMatchesRequest(req, resolved, logPrefix) {
// Invalidate the cached ID so future requests don't reuse it.
if req.ISRC != "" { if req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, 0) GetTrackIDCache().SetTidal(req.ISRC, 0)
} }
@@ -2172,13 +2169,26 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title) GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
} }
// Use track_number / disc_number from the actual Tidal API data when the
// request doesn't carry them (e.g. downloads from search results / popular).
resolvedTrackNumber := req.TrackNumber
resolvedDiscNumber := req.DiscNumber
if actualTrack != nil {
if resolvedTrackNumber == 0 && actualTrack.TrackNumber > 0 {
resolvedTrackNumber = actualTrack.TrackNumber
}
if resolvedDiscNumber == 0 && actualTrack.VolumeNumber > 0 {
resolvedDiscNumber = actualTrack.VolumeNumber
}
}
track := &TidalTrack{ track := &TidalTrack{
ID: trackID, ID: trackID,
Title: strings.TrimSpace(req.TrackName), Title: strings.TrimSpace(req.TrackName),
ISRC: strings.TrimSpace(req.ISRC), ISRC: strings.TrimSpace(req.ISRC),
Duration: expectedDurationSec, Duration: expectedDurationSec,
TrackNumber: req.TrackNumber, TrackNumber: resolvedTrackNumber,
VolumeNumber: req.DiscNumber, VolumeNumber: resolvedDiscNumber,
} }
track.Artist.Name = strings.TrimSpace(req.ArtistName) track.Artist.Name = strings.TrimSpace(req.ArtistName)
track.Album.Title = strings.TrimSpace(req.AlbumName) track.Album.Title = strings.TrimSpace(req.AlbumName)
@@ -2206,7 +2216,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
quality := req.Quality quality := req.Quality
if quality == "" { if quality == "" || quality == "DEFAULT" {
quality = "LOSSLESS" quality = "LOSSLESS"
} }
@@ -2348,18 +2358,21 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
metadata := Metadata{ metadata := Metadata{
Title: req.TrackName, Title: req.TrackName,
Artist: req.ArtistName, Artist: req.ArtistName,
Album: req.AlbumName, Album: req.AlbumName,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
Date: releaseDate, ArtistTagMode: req.ArtistTagMode,
TrackNumber: actualTrackNumber, Date: releaseDate,
TotalTracks: req.TotalTracks, TrackNumber: actualTrackNumber,
DiscNumber: actualDiscNumber, TotalTracks: req.TotalTracks,
ISRC: track.ISRC, DiscNumber: actualDiscNumber,
Genre: req.Genre, TotalDiscs: req.TotalDiscs,
Label: req.Label, ISRC: track.ISRC,
Copyright: req.Copyright, Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Composer: req.Composer,
} }
var coverData []byte var coverData []byte
@@ -2490,7 +2503,6 @@ func parseTidalURL(input string) (string, string, error) {
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
// Handle /browse/track/123 format
if len(parts) > 0 && parts[0] == "browse" { if len(parts) > 0 && parts[0] == "browse" {
parts = parts[1:] parts = parts[1:]
} }
+38 -25
View File
@@ -7,8 +7,21 @@ import (
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
) )
// normalizeLooseTitle collapses separators/punctuation so titles like func writeNormalizedArtistRune(b *strings.Builder, r rune) {
// "Doctor / Cops" and "Doctor _ Cops" can still match. switch r {
case 'đ':
b.WriteString("dj")
case 'ß':
b.WriteString("ss")
case 'æ':
b.WriteString("ae")
case 'œ':
b.WriteString("oe")
default:
b.WriteRune(r)
}
}
func normalizeLooseTitle(title string) string { func normalizeLooseTitle(title string) string {
trimmed := strings.TrimSpace(strings.ToLower(title)) trimmed := strings.TrimSpace(strings.ToLower(title))
if trimmed == "" { if trimmed == "" {
@@ -33,8 +46,6 @@ func normalizeLooseTitle(title string) string {
return strings.Join(strings.Fields(b.String()), " ") return strings.Join(strings.Fields(b.String()), " ")
} }
// normalizeLooseArtistName folds diacritics and common separators so artist
// verification is resilient to variants like "Özkent" vs "Ozkent".
func normalizeLooseArtistName(name string) string { func normalizeLooseArtistName(name string) string {
trimmed := strings.TrimSpace(strings.ToLower(name)) trimmed := strings.TrimSpace(strings.ToLower(name))
if trimmed == "" { if trimmed == "" {
@@ -51,7 +62,7 @@ func normalizeLooseArtistName(name string) string {
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r): case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
continue continue
case unicode.IsLetter(r), unicode.IsNumber(r): case unicode.IsLetter(r), unicode.IsNumber(r):
b.WriteRune(r) writeNormalizedArtistRune(&b, r)
case unicode.IsSpace(r): case unicode.IsSpace(r):
b.WriteByte(' ') b.WriteByte(' ')
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
@@ -72,9 +83,6 @@ func hasAlphaNumericRunes(value string) bool {
return false return false
} }
// normalizeSymbolOnlyTitle keeps symbol/emoji runes while dropping letters,
// digits, spaces and punctuation. This is useful for emoji-only titles such as
// "🪐", "🌎" etc, so we can compare them strictly and avoid false matches.
func normalizeSymbolOnlyTitle(title string) string { func normalizeSymbolOnlyTitle(title string) string {
trimmed := strings.TrimSpace(strings.ToLower(title)) trimmed := strings.TrimSpace(strings.ToLower(title))
if trimmed == "" { if trimmed == "" {
@@ -99,28 +107,33 @@ func normalizeSymbolOnlyTitle(title string) string {
return b.String() return b.String()
} }
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
type resolvedTrackInfo struct { type resolvedTrackInfo struct {
Title string Title string
ArtistName string ArtistName string
Duration int ISRC string
Duration int
SkipNameVerification bool
} }
// trackMatchesRequest checks whether a resolved track from a provider matches
// the original download request. Returns true if the track is a plausible match.
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool { func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
if req.ArtistName != "" && resolved.ArtistName != "" && exactISRCMatch := req.ISRC != "" &&
!artistsMatch(req.ArtistName, resolved.ArtistName) { resolved.ISRC != "" &&
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n", strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC))
logPrefix, req.ArtistName, resolved.ArtistName)
return false
}
if req.TrackName != "" && resolved.Title != "" && if !exactISRCMatch && !resolved.SkipNameVerification {
!titlesMatch(req.TrackName, resolved.Title) { if req.ArtistName != "" && resolved.ArtistName != "" &&
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n", !artistsMatch(req.ArtistName, resolved.ArtistName) {
logPrefix, req.TrackName, resolved.Title) GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
return false logPrefix, req.ArtistName, resolved.ArtistName)
return false
}
if req.TrackName != "" && resolved.Title != "" &&
!titlesMatch(req.TrackName, resolved.Title) {
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
logPrefix, req.TrackName, resolved.Title)
return false
}
} }
expectedDurationSec := req.DurationMS / 1000 expectedDurationSec := req.DurationMS / 1000
+34
View File
@@ -21,6 +21,40 @@ func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) {
} }
} }
func TestTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
req := DownloadRequest{
TrackName: "Ringišpil",
ArtistName: "Djordje Balasevic",
}
resolved := resolvedTrackInfo{
Title: "Completely Different Title",
ArtistName: "Totally Different Artist",
SkipNameVerification: true,
}
if !trackMatchesRequest(req, resolved, "test") {
t.Fatal("expected SongLink-resolved track to bypass artist/title verification")
}
}
func TestTrackMatchesRequest_SongLinkStillChecksDuration(t *testing.T) {
req := DownloadRequest{
TrackName: "Ringišpil",
ArtistName: "Djordje Balasevic",
DurationMS: 180000,
}
resolved := resolvedTrackInfo{
Title: "Completely Different Title",
ArtistName: "Totally Different Artist",
Duration: 240,
SkipNameVerification: true,
}
if trackMatchesRequest(req, resolved, "test") {
t.Fatal("expected SongLink-resolved track with large duration mismatch to be rejected")
}
}
func TestTitlesMatch_SeparatorVariants(t *testing.T) { func TestTitlesMatch_SeparatorVariants(t *testing.T) {
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") { if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
t.Fatal("expected tidal titlesMatch to accept / vs _ variant") t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
+33
View File
@@ -27,6 +27,37 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
flutter_ios_podfile_setup flutter_ios_podfile_setup
def patch_device_info_plus_vision_selector
plugin_file = File.join(
__dir__,
'.symlinks',
'plugins',
'device_info_plus',
'ios',
'device_info_plus',
'Sources',
'device_info_plus',
'FPPDeviceInfoPlusPlugin.m'
)
return unless File.exist?(plugin_file)
source = File.read(plugin_file)
return if source.include?('FPPDeviceInfoPlusVisionCompat')
marker = "#import <sys/utsname.h>\n"
declaration = <<~OBJC
// Older Xcode SDKs do not declare this selector yet, but device_info_plus
// only calls it behind an availability check.
@interface NSProcessInfo (FPPDeviceInfoPlusVisionCompat)
- (BOOL)isiOSAppOnVision;
@end
OBJC
patched = source.sub(marker, "#{marker}#{declaration}\n")
File.write(plugin_file, patched) if patched != source
end
target 'Runner' do target 'Runner' do
use_frameworks! use_frameworks!
use_modular_headers! use_modular_headers!
@@ -42,6 +73,8 @@ target 'RunnerTests' do
end end
post_install do |installer| post_install do |installer|
patch_device_info_plus_vision_selector
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target) flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config| target.build_configurations.each do |config|
+27 -20
View File
@@ -89,7 +89,7 @@ import Gobackend // Import Go framework
} }
self.lastDownloadProgressPayload = payload self.lastDownloadProgressPayload = payload
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.downloadProgressEventSink?(payload) self?.downloadProgressEventSink?(self?.parseJsonPayload(payload))
} }
} }
downloadProgressTimer = timer downloadProgressTimer = timer
@@ -119,7 +119,7 @@ import Gobackend // Import Go framework
} }
self.lastLibraryScanProgressPayload = payload self.lastLibraryScanProgressPayload = payload
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.libraryScanProgressEventSink?(payload) self?.libraryScanProgressEventSink?(self?.parseJsonPayload(payload))
} }
} }
libraryScanProgressTimer = timer libraryScanProgressTimer = timer
@@ -133,6 +133,17 @@ import Gobackend // Import Go framework
libraryScanProgressEventSink = nil libraryScanProgressEventSink = nil
lastLibraryScanProgressPayload = nil lastLibraryScanProgressPayload = nil
} }
private func parseJsonPayload(_ payload: String) -> Any {
guard let data = payload.data(using: .utf8) else {
return payload
}
do {
return try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
} catch {
return payload
}
}
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
@@ -153,13 +164,6 @@ import Gobackend // Import Go framework
var error: NSError? var error: NSError?
switch call.method { switch call.method {
case "parseSpotifyUrl":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendParseSpotifyURL(url, &error)
if let error = error { throw error }
return response
case "checkAvailability": case "checkAvailability":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String let spotifyId = args["spotify_id"] as! String
@@ -176,11 +180,11 @@ import Gobackend // Import Go framework
case "getDownloadProgress": case "getDownloadProgress":
let response = GobackendGetDownloadProgress() let response = GobackendGetDownloadProgress()
return response return parseJsonPayload(response as String? ?? "{}")
case "getAllDownloadProgress": case "getAllDownloadProgress":
let response = GobackendGetAllDownloadProgress() let response = GobackendGetAllDownloadProgress()
return response return parseJsonPayload(response as String? ?? "{}")
case "initItemProgress": case "initItemProgress":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
@@ -303,6 +307,15 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "rewriteSplitArtistTags":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let artist = args["artist"] as! String
let albumArtist = args["album_artist"] as! String
let response = GobackendRewriteSplitArtistTagsExport(filePath, artist, albumArtist, &error)
if let error = error { throw error }
return response
case "cleanupConnections": case "cleanupConnections":
GobackendCleanupConnections() GobackendCleanupConnections()
return nil return nil
@@ -331,7 +344,8 @@ import Gobackend // Import Go framework
let spotifyId = args["spotify_id"] as! String let spotifyId = args["spotify_id"] as! String
let durationMs = args["duration_ms"] as? Int64 ?? 0 let durationMs = args["duration_ms"] as? Int64 ?? 0
let outputPath = args["output_path"] as! String let outputPath = args["output_path"] as! String
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, &error) let audioFilePath = args["audio_file_path"] as? String ?? ""
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, audioFilePath, &error)
if let error = error { throw error } if let error = error { throw error }
return "{\"success\":true}" return "{\"success\":true}"
@@ -469,13 +483,6 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "getSpotifyMetadataWithFallback":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendGetSpotifyMetadataWithDeezerFallback(url, &error)
if let error = error { throw error }
return response
case "checkAvailabilityFromDeezerID": case "checkAvailabilityFromDeezerID":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String let deezerTrackId = args["deezer_track_id"] as! String
@@ -937,7 +944,7 @@ import Gobackend // Import Go framework
case "getLibraryScanProgress": case "getLibraryScanProgress":
let response = GobackendGetLibraryScanProgressJSON() let response = GobackendGetLibraryScanProgressJSON()
return response return parseJsonPayload(response as String? ?? "{}")
case "cancelLibraryScan": case "cancelLibraryScan":
GobackendCancelLibraryScanJSON() GobackendCancelLibraryScanJSON()
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
/// 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 = '4.1.0'; static const String version = '4.2.1';
static const String buildNumber = '117'; static const String buildNumber = '122';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release. /// Shows "Internal" in debug builds, actual version in release.
+497 -13
View File
@@ -151,7 +151,7 @@ abstract class AppLocalizations {
/// Bottom navigation - Extension store tab /// Bottom navigation - Extension store tab
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Store'** /// **'Repo'**
String get navStore; String get navStore;
/// Home screen title /// Home screen title
@@ -163,7 +163,7 @@ abstract class AppLocalizations {
/// Subtitle shown below search box /// Subtitle shown below search box
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Paste a Spotify link or search by name'** /// **'Paste a supported URL or search by name'**
String get homeSubtitle; String get homeSubtitle;
/// Info text about supported URL types /// Info text about supported URL types
@@ -256,6 +256,18 @@ abstract class AppLocalizations {
/// **'Filename Format'** /// **'Filename Format'**
String get downloadFilenameFormat; String get downloadFilenameFormat;
/// Setting for output filename pattern for singles/EPs
///
/// In en, this message translates to:
/// **'Single Filename Format'**
String get downloadSingleFilenameFormat;
/// Subtitle description for single filename format setting
///
/// In en, this message translates to:
/// **'Filename pattern for singles and EPs. Uses the same tags as the album format.'**
String get downloadSingleFilenameFormatDescription;
/// Title of the folder organization picker bottom sheet /// Title of the folder organization picker bottom sheet
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -400,6 +412,60 @@ abstract class AppLocalizations {
/// **'Download highest resolution cover art'** /// **'Download highest resolution cover art'**
String get optionsMaxQualityCoverSubtitle; String get optionsMaxQualityCoverSubtitle;
/// Title for ReplayGain setting toggle
///
/// In en, this message translates to:
/// **'ReplayGain'**
String get optionsReplayGain;
/// Subtitle when ReplayGain is enabled
///
/// In en, this message translates to:
/// **'Scan loudness and embed ReplayGain tags (EBU R128)'**
String get optionsReplayGainSubtitleOn;
/// Subtitle when ReplayGain is disabled
///
/// In en, this message translates to:
/// **'Disabled: no loudness normalization tags'**
String get optionsReplayGainSubtitleOff;
/// Setting title for how artist metadata is written into files
///
/// In en, this message translates to:
/// **'Artist Tag Mode'**
String get optionsArtistTagMode;
/// Bottom-sheet description for artist tag mode setting
///
/// In en, this message translates to:
/// **'Choose how multiple artists are written into embedded tags.'**
String get optionsArtistTagModeDescription;
/// Artist tag mode option that joins multiple artists into one value
///
/// In en, this message translates to:
/// **'Single joined value'**
String get optionsArtistTagModeJoined;
/// Subtitle for joined artist tag mode
///
/// In en, this message translates to:
/// **'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'**
String get optionsArtistTagModeJoinedSubtitle;
/// Artist tag mode option that writes repeated ARTIST tags for Vorbis formats
///
/// In en, this message translates to:
/// **'Split tags for FLAC/Opus'**
String get optionsArtistTagModeSplitVorbis;
/// Subtitle for split Vorbis artist tag mode
///
/// In en, this message translates to:
/// **'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'**
String get optionsArtistTagModeSplitVorbisSubtitle;
/// Number of parallel downloads /// Number of parallel downloads
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -427,13 +493,13 @@ abstract class AppLocalizations {
/// Show/hide store tab /// Show/hide store tab
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Extension Store'** /// **'Extension Repo'**
String get optionsExtensionStore; String get optionsExtensionStore;
/// Subtitle for extension store toggle /// Subtitle for extension store toggle
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Show Store tab in navigation'** /// **'Show Repo tab in navigation'**
String get optionsExtensionStoreSubtitle; String get optionsExtensionStoreSubtitle;
/// Auto update check toggle /// Auto update check toggle
@@ -565,7 +631,7 @@ abstract class AppLocalizations {
/// Store screen title /// Store screen title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Extension Store'** /// **'Extension Repo'**
String get storeTitle; String get storeTitle;
/// Store search placeholder /// Store search placeholder
@@ -2182,6 +2248,18 @@ abstract class AppLocalizations {
/// **'Lyrics not available for this track'** /// **'Lyrics not available for this track'**
String get trackLyricsNotAvailable; String get trackLyricsNotAvailable;
/// Message when no embedded lyrics in audio file
///
/// In en, this message translates to:
/// **'No lyrics found in this file'**
String get trackLyricsNotInFile;
/// Action - fetch lyrics from online providers
///
/// In en, this message translates to:
/// **'Fetch from Online'**
String get trackFetchOnlineLyrics;
/// Message when lyrics request times out /// Message when lyrics request times out
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2365,7 +2443,7 @@ abstract class AppLocalizations {
/// Error heading when the store cannot be loaded /// Error heading when the store cannot be loaded
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Failed to load store'** /// **'Failed to load repository'**
String get storeLoadError; String get storeLoadError;
/// Message when store has no extensions /// Message when store has no extensions
@@ -3334,6 +3412,12 @@ abstract class AppLocalizations {
/// **'{count, plural, =1{track} other{tracks}}'** /// **'{count, plural, =1{track} other{tracks}}'**
String libraryTracksUnit(int count); String libraryTracksUnit(int count);
/// Unit label for files count during library scanning
///
/// In en, this message translates to:
/// **'{count, plural, =1{file} other{files}}'**
String libraryFilesUnit(int count);
/// Last scan time display /// Last scan time display
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3352,6 +3436,12 @@ abstract class AppLocalizations {
/// **'Scanning...'** /// **'Scanning...'**
String get libraryScanning; String get libraryScanning;
/// Status shown after file scanning finishes but library persistence is still running
///
/// In en, this message translates to:
/// **'Finalizing library...'**
String get libraryScanFinalizing;
/// Scan progress display /// Scan progress display
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3478,6 +3568,42 @@ abstract class AppLocalizations {
/// **'Format'** /// **'Format'**
String get libraryFilterFormat; String get libraryFilterFormat;
/// Filter section - metadata completeness
///
/// In en, this message translates to:
/// **'Metadata'**
String get libraryFilterMetadata;
/// Filter option - items with complete metadata
///
/// In en, this message translates to:
/// **'Complete metadata'**
String get libraryFilterMetadataComplete;
/// Filter option - items missing any tracked metadata field
///
/// In en, this message translates to:
/// **'Missing any metadata'**
String get libraryFilterMetadataMissingAny;
/// Filter option - items missing release year/date
///
/// In en, this message translates to:
/// **'Missing year'**
String get libraryFilterMetadataMissingYear;
/// Filter option - items missing genre
///
/// In en, this message translates to:
/// **'Missing genre'**
String get libraryFilterMetadataMissingGenre;
/// Filter option - items missing album artist
///
/// In en, this message translates to:
/// **'Missing album artist'**
String get libraryFilterMetadataMissingAlbumArtist;
/// Filter section - sort order /// Filter section - sort order
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3496,6 +3622,30 @@ abstract class AppLocalizations {
/// **'Oldest'** /// **'Oldest'**
String get libraryFilterSortOldest; String get libraryFilterSortOldest;
/// Sort option - album ascending
///
/// In en, this message translates to:
/// **'Album (A-Z)'**
String get libraryFilterSortAlbumAsc;
/// Sort option - album descending
///
/// In en, this message translates to:
/// **'Album (Z-A)'**
String get libraryFilterSortAlbumDesc;
/// Sort option - genre ascending
///
/// In en, this message translates to:
/// **'Genre (A-Z)'**
String get libraryFilterSortGenreAsc;
/// Sort option - genre descending
///
/// In en, this message translates to:
/// **'Genre (Z-A)'**
String get libraryFilterSortGenreDesc;
/// Relative time - less than a minute ago /// Relative time - less than a minute ago
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3613,7 +3763,7 @@ abstract class AppLocalizations {
/// Tutorial extensions tip 1 /// Tutorial extensions tip 1
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Browse the Store tab to discover useful extensions'** /// **'Browse the Repo tab to discover useful extensions'**
String get tutorialExtensionsTip1; String get tutorialExtensionsTip1;
/// Tutorial extensions tip 2 /// Tutorial extensions tip 2
@@ -3940,6 +4090,54 @@ abstract class AppLocalizations {
/// **'Search metadata online and embed into file'** /// **'Search metadata online and embed into file'**
String get trackReEnrichOnlineSubtitle; String get trackReEnrichOnlineSubtitle;
/// Section title for field selection in re-enrich dialog
///
/// In en, this message translates to:
/// **'Fields to update'**
String get trackReEnrichFieldsTitle;
/// Checkbox label for cover art field in re-enrich
///
/// In en, this message translates to:
/// **'Cover Art'**
String get trackReEnrichFieldCover;
/// Checkbox label for lyrics field in re-enrich
///
/// In en, this message translates to:
/// **'Lyrics'**
String get trackReEnrichFieldLyrics;
/// Checkbox label for basic tags in re-enrich (title/artist are never overwritten)
///
/// In en, this message translates to:
/// **'Album, Album Artist'**
String get trackReEnrichFieldBasicTags;
/// Checkbox label for track info in re-enrich
///
/// In en, this message translates to:
/// **'Track & Disc Number'**
String get trackReEnrichFieldTrackInfo;
/// Checkbox label for release info in re-enrich
///
/// In en, this message translates to:
/// **'Date & ISRC'**
String get trackReEnrichFieldReleaseInfo;
/// Checkbox label for extra metadata in re-enrich
///
/// In en, this message translates to:
/// **'Genre, Label, Copyright'**
String get trackReEnrichFieldExtra;
/// Select all fields checkbox in re-enrich
///
/// In en, this message translates to:
/// **'Select All'**
String get trackReEnrichSelectAll;
/// Menu action - edit embedded metadata /// Menu action - edit embedded metadata
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4539,12 +4737,6 @@ abstract class AppLocalizations {
/// **'You have unsaved changes that will be lost.'** /// **'You have unsaved changes that will be lost.'**
String get lyricsProvidersDiscardContent; String get lyricsProvidersDiscardContent;
/// Description for Spotify Lyrics API provider
///
/// In en, this message translates to:
/// **'Spotify-sourced synced lyrics via community API'**
String get lyricsProviderSpotifyApiDesc;
/// Description for LRCLIB provider /// Description for LRCLIB provider
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -5300,6 +5492,298 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Samples'** /// **'Samples'**
String get audioAnalysisSamples; String get audioAnalysisSamples;
/// Extensions page - subtitle for built-in search provider option
///
/// In en, this message translates to:
/// **'Search with {providerName}'**
String extensionsSearchWith(String providerName);
/// Extensions page - label for home feed provider selector
///
/// In en, this message translates to:
/// **'Home Feed Provider'**
String get extensionsHomeFeedProvider;
/// Extensions page - description for home feed provider picker
///
/// In en, this message translates to:
/// **'Choose which extension provides the home feed on the main screen'**
String get extensionsHomeFeedDescription;
/// Extensions page - home feed provider option: auto
///
/// In en, this message translates to:
/// **'Auto'**
String get extensionsHomeFeedAuto;
/// Extensions page - subtitle for auto home feed option
///
/// In en, this message translates to:
/// **'Automatically select the best available'**
String get extensionsHomeFeedAutoSubtitle;
/// Extensions page - subtitle for a specific extension home feed option
///
/// In en, this message translates to:
/// **'Use {extensionName} home feed'**
String extensionsHomeFeedUse(String extensionName);
/// Extensions page - shown when no installed extension has home feed
///
/// In en, this message translates to:
/// **'No extensions with home feed'**
String get extensionsNoHomeFeedExtensions;
/// Sort option - alphabetical ascending
///
/// In en, this message translates to:
/// **'A-Z'**
String get sortAlphaAsc;
/// Sort option - alphabetical descending
///
/// In en, this message translates to:
/// **'Z-A'**
String get sortAlphaDesc;
/// Dialog title when confirming cancellation of an active download
///
/// In en, this message translates to:
/// **'Cancel download?'**
String get cancelDownloadTitle;
/// Dialog body when confirming cancellation of an active download
///
/// In en, this message translates to:
/// **'This will cancel the active download for \"{trackName}\".'**
String cancelDownloadContent(String trackName);
/// Dialog button - keep the active download (do not cancel)
///
/// In en, this message translates to:
/// **'Keep'**
String get cancelDownloadKeep;
/// Snackbar error when FFmpeg fails to write metadata
///
/// In en, this message translates to:
/// **'Failed to save metadata via FFmpeg'**
String get metadataSaveFailedFfmpeg;
/// Snackbar error when writing metadata file back to storage fails
///
/// In en, this message translates to:
/// **'Failed to write metadata back to storage'**
String get metadataSaveFailedStorage;
/// Snackbar shown when folder picker fails to open
///
/// In en, this message translates to:
/// **'Failed to open folder picker: {error}'**
String snackbarFolderPickerFailed(String error);
/// Error state shown when album fails to load
///
/// In en, this message translates to:
/// **'Failed to load album'**
String get errorLoadAlbum;
/// Error state shown when playlist fails to load
///
/// In en, this message translates to:
/// **'Failed to load playlist'**
String get errorLoadPlaylist;
/// Error state shown when artist fails to load
///
/// In en, this message translates to:
/// **'Failed to load artist'**
String get errorLoadArtist;
/// Android notification channel name for download progress
///
/// In en, this message translates to:
/// **'Download Progress'**
String get notifChannelDownloadName;
/// Android notification channel description for download progress
///
/// In en, this message translates to:
/// **'Shows download progress for tracks'**
String get notifChannelDownloadDesc;
/// Android notification channel name for library scan
///
/// In en, this message translates to:
/// **'Library Scan'**
String get notifChannelLibraryScanName;
/// Android notification channel description for library scan
///
/// In en, this message translates to:
/// **'Shows local library scan progress'**
String get notifChannelLibraryScanDesc;
/// Notification title while downloading a track
///
/// In en, this message translates to:
/// **'Downloading {trackName}'**
String notifDownloadingTrack(String trackName);
/// Notification title while finalizing (embedding metadata) a track
///
/// In en, this message translates to:
/// **'Finalizing {trackName}'**
String notifFinalizingTrack(String trackName);
/// Notification body while embedding metadata into a downloaded track
///
/// In en, this message translates to:
/// **'Embedding metadata...'**
String get notifEmbeddingMetadata;
/// Notification title when track is already in library, with count
///
/// In en, this message translates to:
/// **'Already in Library ({completed}/{total})'**
String notifAlreadyInLibraryCount(int completed, int total);
/// Notification title when track is already in library
///
/// In en, this message translates to:
/// **'Already in Library'**
String get notifAlreadyInLibrary;
/// Notification title when download is complete, with count
///
/// In en, this message translates to:
/// **'Download Complete ({completed}/{total})'**
String notifDownloadCompleteCount(int completed, int total);
/// Notification title when a single download is complete
///
/// In en, this message translates to:
/// **'Download Complete'**
String get notifDownloadComplete;
/// Notification title when queue finishes with some failures
///
/// In en, this message translates to:
/// **'Downloads Finished ({completed} done, {failed} failed)'**
String notifDownloadsFinished(int completed, int failed);
/// Notification title when all downloads finish successfully
///
/// In en, this message translates to:
/// **'All Downloads Complete'**
String get notifAllDownloadsComplete;
/// Notification body for queue complete - how many tracks were downloaded
///
/// In en, this message translates to:
/// **'{count} tracks downloaded successfully'**
String notifTracksDownloadedSuccess(int count);
/// Notification title while scanning local library
///
/// In en, this message translates to:
/// **'Scanning local library'**
String get notifScanningLibrary;
/// Notification body for library scan progress when total is known
///
/// In en, this message translates to:
/// **'{scanned}/{total} files • {percentage}%'**
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
);
/// Notification body for library scan progress when total is unknown
///
/// In en, this message translates to:
/// **'{scanned} files scanned • {percentage}%'**
String notifLibraryScanProgressNoTotal(int scanned, int percentage);
/// Notification title when library scan finishes
///
/// In en, this message translates to:
/// **'Library scan complete'**
String get notifLibraryScanComplete;
/// Notification body for library scan complete - number of indexed tracks
///
/// In en, this message translates to:
/// **'{count} tracks indexed'**
String notifLibraryScanCompleteBody(int count);
/// Library scan complete suffix - excluded track count
///
/// In en, this message translates to:
/// **'{count} excluded'**
String notifLibraryScanExcluded(int count);
/// Library scan complete suffix - error count
///
/// In en, this message translates to:
/// **'{count} errors'**
String notifLibraryScanErrors(int count);
/// Notification title when library scan fails
///
/// In en, this message translates to:
/// **'Library scan failed'**
String get notifLibraryScanFailed;
/// Notification title when library scan is cancelled by the user
///
/// In en, this message translates to:
/// **'Library scan cancelled'**
String get notifLibraryScanCancelled;
/// Notification body when library scan is cancelled
///
/// In en, this message translates to:
/// **'Scan stopped before completion.'**
String get notifLibraryScanStopped;
/// Notification title while downloading an app update
///
/// In en, this message translates to:
/// **'Downloading SpotiFLAC v{version}'**
String notifDownloadingUpdate(String version);
/// Notification body showing update download progress
///
/// In en, this message translates to:
/// **'{received} / {total} MB • {percentage}%'**
String notifUpdateProgress(String received, String total, int percentage);
/// Notification title when app update download is complete
///
/// In en, this message translates to:
/// **'Update Ready'**
String get notifUpdateReady;
/// Notification body when app update is ready to install
///
/// In en, this message translates to:
/// **'SpotiFLAC v{version} downloaded. Tap to install.'**
String notifUpdateReadyBody(String version);
/// Notification title when app update download fails
///
/// In en, this message translates to:
/// **'Update Failed'**
String get notifUpdateFailed;
/// Notification body when app update download fails
///
/// In en, this message translates to:
/// **'Could not download update. Try again later.'**
String get notifUpdateFailedBody;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+302 -5
View File
@@ -76,6 +76,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get downloadFilenameFormat => 'Dateinamenformat'; String get downloadFilenameFormat => 'Dateinamenformat';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => 'Ordnerstruktur'; String get downloadFolderOrganization => 'Ordnerstruktur';
@@ -158,6 +165,38 @@ class AppLocalizationsDe extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Cover in höchster Auflösung herunterladen'; 'Cover in höchster Auflösung herunterladen';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Parallele Downloads'; String get optionsConcurrentDownloads => 'Parallele Downloads';
@@ -1180,6 +1219,12 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackLyricsNotAvailable => String get trackLyricsNotAvailable =>
'Lyrics sind für diesen Titel nicht verfügbar'; 'Lyrics sind für diesen Titel nicht verfügbar';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => String get trackLyricsTimeout =>
'Anfrage Timeout. Versuche es später erneut.'; 'Anfrage Timeout. Versuche es später erneut.';
@@ -1281,7 +1326,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1851,6 +1896,17 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Zuletzt gescannt: $time'; return 'Zuletzt gescannt: $time';
@@ -1862,6 +1918,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get libraryScanning => 'Scannen...'; String get libraryScanning => 'Scannen...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% von $total Dateien'; return '$progress% von $total Dateien';
@@ -1930,6 +1989,24 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sortieren'; String get libraryFilterSort => 'Sortieren';
@@ -1939,6 +2016,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Älteste'; String get libraryFilterSortOldest => 'Älteste';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Gerade eben'; String get timeJustNow => 'Gerade eben';
@@ -2225,6 +2314,30 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Metadaten online suchen und in Datei einbinden'; 'Metadaten online suchen und in Datei einbinden';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'Metadaten bearbeiten'; String get trackEditMetadata => 'Metadaten bearbeiten';
@@ -2641,10 +2754,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3124,4 +3233,192 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+308 -11
View File
@@ -21,13 +21,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get navSettings => 'Settings'; String get navSettings => 'Settings';
@override @override
String get navStore => 'Store'; String get navStore => 'Repo';
@override @override
String get homeTitle => 'Home'; String get homeTitle => 'Home';
@override @override
String get homeSubtitle => 'Paste a Spotify link or search by name'; String get homeSubtitle => 'Paste a supported URL or search by name';
@override @override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -75,6 +75,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get downloadFilenameFormat => 'Filename Format'; String get downloadFilenameFormat => 'Filename Format';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => 'Folder Organization'; String get downloadFolderOrganization => 'Folder Organization';
@@ -154,6 +161,38 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -170,10 +209,10 @@ class AppLocalizationsEn extends AppLocalizations {
'Parallel downloads may trigger rate limiting'; 'Parallel downloads may trigger rate limiting';
@override @override
String get optionsExtensionStore => 'Extension Store'; String get optionsExtensionStore => 'Extension Repo';
@override @override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
@override @override
String get optionsCheckUpdates => 'Check for Updates'; String get optionsCheckUpdates => 'Check for Updates';
@@ -250,7 +289,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get extensionsUninstall => 'Uninstall'; String get extensionsUninstall => 'Uninstall';
@override @override
String get storeTitle => 'Extension Store'; String get storeTitle => 'Extension Repo';
@override @override
String get storeSearch => 'Search extensions...'; String get storeSearch => 'Search extensions...';
@@ -1161,6 +1200,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get trackLyricsNotAvailable => 'Lyrics not available for this track'; String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => 'Request timed out. Try again later.'; String get trackLyricsTimeout => 'Request timed out. Try again later.';
@@ -1261,7 +1306,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1868,17 @@ class AppLocalizationsEn extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1834,6 +1890,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1902,6 +1961,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1911,6 +1988,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -1997,7 +2086,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Browse the Repo tab to discover useful extensions';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -2195,6 +2284,30 @@ class AppLocalizationsEn extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file'; 'Search metadata online and embed into file';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'Edit Metadata'; String get trackEditMetadata => 'Edit Metadata';
@@ -2609,10 +2722,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3092,4 +3201,192 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+303 -6
View File
@@ -75,6 +75,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get downloadFilenameFormat => 'Filename Format'; String get downloadFilenameFormat => 'Filename Format';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => 'Folder Organization'; String get downloadFolderOrganization => 'Folder Organization';
@@ -154,6 +161,38 @@ class AppLocalizationsEs extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1161,6 +1200,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get trackLyricsNotAvailable => 'Lyrics not available for this track'; String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => 'Request timed out. Try again later.'; String get trackLyricsTimeout => 'Request timed out. Try again later.';
@@ -1261,7 +1306,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1868,17 @@ class AppLocalizationsEs extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1834,6 +1890,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1902,6 +1961,24 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1911,6 +1988,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -1997,7 +2086,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Browse the Repo tab to discover useful extensions';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -2195,6 +2284,30 @@ class AppLocalizationsEs extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file'; 'Search metadata online and embed into file';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'Edit Metadata'; String get trackEditMetadata => 'Edit Metadata';
@@ -2609,10 +2722,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3092,6 +3201,194 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
/// The translations for Spanish Castilian, as used in Spain (`es_ES`). /// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+302 -5
View File
@@ -75,6 +75,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get downloadFilenameFormat => 'Nom du fichier'; String get downloadFilenameFormat => 'Nom du fichier';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => 'Organisation du dossier'; String get downloadFolderOrganization => 'Organisation du dossier';
@@ -156,6 +163,38 @@ class AppLocalizationsFr extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1163,6 +1202,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get trackLyricsNotAvailable => 'Lyrics not available for this track'; String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => 'Request timed out. Try again later.'; String get trackLyricsTimeout => 'Request timed out. Try again later.';
@@ -1263,7 +1308,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1825,6 +1870,17 @@ class AppLocalizationsFr extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1836,6 +1892,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1904,6 +1963,24 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1913,6 +1990,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -2197,6 +2286,30 @@ class AppLocalizationsFr extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file'; 'Search metadata online and embed into file';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'Edit Metadata'; String get trackEditMetadata => 'Edit Metadata';
@@ -2610,10 +2723,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3093,4 +3202,192 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+302 -5
View File
@@ -75,6 +75,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get downloadFilenameFormat => 'Filename Format'; String get downloadFilenameFormat => 'Filename Format';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => 'Folder Organization'; String get downloadFolderOrganization => 'Folder Organization';
@@ -154,6 +161,38 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1161,6 +1200,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get trackLyricsNotAvailable => 'Lyrics not available for this track'; String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => 'Request timed out. Try again later.'; String get trackLyricsTimeout => 'Request timed out. Try again later.';
@@ -1261,7 +1306,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1868,17 @@ class AppLocalizationsHi extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1834,6 +1890,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1902,6 +1961,24 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1911,6 +1988,18 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -2195,6 +2284,30 @@ class AppLocalizationsHi extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file'; 'Search metadata online and embed into file';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'Edit Metadata'; String get trackEditMetadata => 'Edit Metadata';
@@ -2608,10 +2721,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3091,4 +3200,192 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+309 -11
View File
@@ -21,13 +21,14 @@ class AppLocalizationsId extends AppLocalizations {
String get navSettings => 'Pengaturan'; String get navSettings => 'Pengaturan';
@override @override
String get navStore => 'Toko'; String get navStore => 'Repo';
@override @override
String get homeTitle => 'Beranda'; String get homeTitle => 'Beranda';
@override @override
String get homeSubtitle => 'Tempel link Spotify atau cari berdasarkan nama'; String get homeSubtitle =>
'Tempel URL yang didukung atau cari berdasarkan nama';
@override @override
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis'; String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
@@ -75,6 +76,13 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get downloadFilenameFormat => 'Format Nama File'; String get downloadFilenameFormat => 'Format Nama File';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => 'Organisasi Folder'; String get downloadFolderOrganization => 'Organisasi Folder';
@@ -157,6 +165,38 @@ class AppLocalizationsId extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Unduh cover art resolusi tertinggi'; 'Unduh cover art resolusi tertinggi';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Unduhan Bersamaan'; String get optionsConcurrentDownloads => 'Unduhan Bersamaan';
@@ -173,10 +213,10 @@ class AppLocalizationsId extends AppLocalizations {
'Unduhan paralel dapat memicu pembatasan rate'; 'Unduhan paralel dapat memicu pembatasan rate';
@override @override
String get optionsExtensionStore => 'Toko Ekstensi'; String get optionsExtensionStore => 'Repo Ekstensi';
@override @override
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Toko di navigasi'; String get optionsExtensionStoreSubtitle => 'Tampilkan tab Repo di navigasi';
@override @override
String get optionsCheckUpdates => 'Periksa Pembaruan'; String get optionsCheckUpdates => 'Periksa Pembaruan';
@@ -252,7 +292,7 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsUninstall => 'Copot'; String get extensionsUninstall => 'Copot';
@override @override
String get storeTitle => 'Toko Ekstensi'; String get storeTitle => 'Repo Ekstensi';
@override @override
String get storeSearch => 'Cari ekstensi...'; String get storeSearch => 'Cari ekstensi...';
@@ -1167,6 +1207,12 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get trackLyricsNotAvailable => 'Lirik tidak tersedia untuk lagu ini'; String get trackLyricsNotAvailable => 'Lirik tidak tersedia untuk lagu ini';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => 'Permintaan timeout. Coba lagi nanti.'; String get trackLyricsTimeout => 'Permintaan timeout. Coba lagi nanti.';
@@ -1267,7 +1313,7 @@ class AppLocalizationsId extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Gagal memuat repo';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1832,6 +1878,17 @@ class AppLocalizationsId extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1843,6 +1900,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1911,6 +1971,24 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1920,6 +1998,18 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -2006,7 +2096,7 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Buka tab Repo untuk menemukan ekstensi yang berguna';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -2204,6 +2294,30 @@ class AppLocalizationsId extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file'; 'Search metadata online and embed into file';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'Edit Metadata'; String get trackEditMetadata => 'Edit Metadata';
@@ -2618,10 +2732,6 @@ class AppLocalizationsId extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3101,4 +3211,192 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+302 -5
View File
@@ -75,6 +75,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get downloadFilenameFormat => 'ファイル名の形式'; String get downloadFilenameFormat => 'ファイル名の形式';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => 'フォルダ構成'; String get downloadFolderOrganization => 'フォルダ構成';
@@ -152,6 +159,38 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード'; String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => '同時ダウンロード'; String get optionsConcurrentDownloads => '同時ダウンロード';
@@ -1155,6 +1194,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get trackLyricsNotAvailable => 'このトラックの歌詞は利用できません'; String get trackLyricsNotAvailable => 'このトラックの歌詞は利用できません';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => 'リクエストがタイムアウトしました。後ほどお試しください。'; String get trackLyricsTimeout => 'リクエストがタイムアウトしました。後ほどお試しください。';
@@ -1255,7 +1300,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1810,6 +1855,17 @@ class AppLocalizationsJa extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return '最終スキャン: $time'; return '最終スキャン: $time';
@@ -1821,6 +1877,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get libraryScanning => 'スキャン中...'; String get libraryScanning => 'スキャン中...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1889,6 +1948,24 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get libraryFilterFormat => '形式'; String get libraryFilterFormat => '形式';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1898,6 +1975,18 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -2182,6 +2271,30 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file'; 'Search metadata online and embed into file';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'メタデータを編集'; String get trackEditMetadata => 'メタデータを編集';
@@ -2595,10 +2708,6 @@ class AppLocalizationsJa extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3078,4 +3187,192 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+302 -5
View File
@@ -74,6 +74,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get downloadFilenameFormat => '파일 이름 형식'; String get downloadFilenameFormat => '파일 이름 형식';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => '폴더 분류 형식'; String get downloadFolderOrganization => '폴더 분류 형식';
@@ -148,6 +155,38 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드'; String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => '동시 다운로드'; String get optionsConcurrentDownloads => '동시 다운로드';
@@ -1141,6 +1180,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get trackLyricsNotAvailable => 'Lyrics not available for this track'; String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => 'Request timed out. Try again later.'; String get trackLyricsTimeout => 'Request timed out. Try again later.';
@@ -1241,7 +1286,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1803,6 +1848,17 @@ class AppLocalizationsKo extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1814,6 +1870,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1882,6 +1941,24 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1891,6 +1968,18 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -2175,6 +2264,30 @@ class AppLocalizationsKo extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file'; 'Search metadata online and embed into file';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'Edit Metadata'; String get trackEditMetadata => 'Edit Metadata';
@@ -2588,10 +2701,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3071,4 +3180,192 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+302 -5
View File
@@ -75,6 +75,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get downloadFilenameFormat => 'Filename Format'; String get downloadFilenameFormat => 'Filename Format';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => 'Folder Organization'; String get downloadFolderOrganization => 'Folder Organization';
@@ -154,6 +161,38 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1161,6 +1200,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get trackLyricsNotAvailable => 'Lyrics not available for this track'; String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => 'Request timed out. Try again later.'; String get trackLyricsTimeout => 'Request timed out. Try again later.';
@@ -1261,7 +1306,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1868,17 @@ class AppLocalizationsNl extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1834,6 +1890,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1902,6 +1961,24 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1911,6 +1988,18 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -2195,6 +2284,30 @@ class AppLocalizationsNl extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file'; 'Search metadata online and embed into file';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'Edit Metadata'; String get trackEditMetadata => 'Edit Metadata';
@@ -2608,10 +2721,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3091,4 +3200,192 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+303 -6
View File
@@ -75,6 +75,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get downloadFilenameFormat => 'Filename Format'; String get downloadFilenameFormat => 'Filename Format';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => 'Folder Organization'; String get downloadFolderOrganization => 'Folder Organization';
@@ -154,6 +161,38 @@ class AppLocalizationsPt extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1161,6 +1200,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get trackLyricsNotAvailable => 'Lyrics not available for this track'; String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => 'Request timed out. Try again later.'; String get trackLyricsTimeout => 'Request timed out. Try again later.';
@@ -1261,7 +1306,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1868,17 @@ class AppLocalizationsPt extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1834,6 +1890,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1902,6 +1961,24 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1911,6 +1988,18 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -1997,7 +2086,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Browse the Repo tab to discover useful extensions';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -2195,6 +2284,30 @@ class AppLocalizationsPt extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file'; 'Search metadata online and embed into file';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'Edit Metadata'; String get trackEditMetadata => 'Edit Metadata';
@@ -2609,10 +2722,6 @@ class AppLocalizationsPt extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3092,6 +3201,194 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
/// The translations for Portuguese, as used in Portugal (`pt_PT`). /// The translations for Portuguese, as used in Portugal (`pt_PT`).
+302 -5
View File
@@ -76,6 +76,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get downloadFilenameFormat => 'Формат имени файла'; String get downloadFilenameFormat => 'Формат имени файла';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => 'Организация папок'; String get downloadFolderOrganization => 'Организация папок';
@@ -159,6 +166,38 @@ class AppLocalizationsRu extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Скачивать обложку в макс. разрешении'; 'Скачивать обложку в макс. разрешении';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Одновременные загрузки'; String get optionsConcurrentDownloads => 'Одновременные загрузки';
@@ -1181,6 +1220,12 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackLyricsNotAvailable => String get trackLyricsNotAvailable =>
'Текст песни недоступен для этого трека'; 'Текст песни недоступен для этого трека';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => String get trackLyricsTimeout =>
'Время ожидания запроса истекло. Повторите попытку позже.'; 'Время ожидания запроса истекло. Повторите попытку позже.';
@@ -1282,7 +1327,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1861,6 +1906,17 @@ class AppLocalizationsRu extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Последнее сканирование: $time'; return 'Последнее сканирование: $time';
@@ -1872,6 +1928,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get libraryScanning => 'Сканирование...'; String get libraryScanning => 'Сканирование...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% из $total файлов'; return '$progress% из $total файлов';
@@ -1948,6 +2007,24 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Формат'; String get libraryFilterFormat => 'Формат';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Сортировка'; String get libraryFilterSort => 'Сортировка';
@@ -1957,6 +2034,18 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Старые'; String get libraryFilterSortOldest => 'Старые';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Только что'; String get timeJustNow => 'Только что';
@@ -2247,6 +2336,30 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Поиск в сети метаданных и встраивание в файл'; 'Поиск в сети метаданных и встраивание в файл';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'Редактировать метаданные'; String get trackEditMetadata => 'Редактировать метаданные';
@@ -2668,10 +2781,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3151,4 +3260,192 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+302 -5
View File
@@ -76,6 +76,13 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get downloadFilenameFormat => 'Dosya adı formatı'; String get downloadFilenameFormat => 'Dosya adı formatı';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => 'Dosya Organizasyonu'; String get downloadFolderOrganization => 'Dosya Organizasyonu';
@@ -157,6 +164,38 @@ class AppLocalizationsTr extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'En yüksek kalitedeki albüm kapaklarını indir'; 'En yüksek kalitedeki albüm kapaklarını indir';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Eş Zamanlı İndirmeler'; String get optionsConcurrentDownloads => 'Eş Zamanlı İndirmeler';
@@ -1167,6 +1206,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get trackLyricsNotAvailable => 'Lyrics not available for this track'; String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => 'Request timed out. Try again later.'; String get trackLyricsTimeout => 'Request timed out. Try again later.';
@@ -1267,7 +1312,7 @@ class AppLocalizationsTr extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1829,6 +1874,17 @@ class AppLocalizationsTr extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1840,6 +1896,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1908,6 +1967,24 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1917,6 +1994,18 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -2201,6 +2290,30 @@ class AppLocalizationsTr extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file'; 'Search metadata online and embed into file';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'Edit Metadata'; String get trackEditMetadata => 'Edit Metadata';
@@ -2614,10 +2727,6 @@ class AppLocalizationsTr extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3097,4 +3206,192 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+303 -6
View File
@@ -75,6 +75,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get downloadFilenameFormat => 'Filename Format'; String get downloadFilenameFormat => 'Filename Format';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
@override @override
String get downloadFolderOrganization => 'Folder Organization'; String get downloadFolderOrganization => 'Folder Organization';
@@ -154,6 +161,38 @@ class AppLocalizationsZh extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsReplayGain => 'ReplayGain';
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1161,6 +1200,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get trackLyricsNotAvailable => 'Lyrics not available for this track'; String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override
String get trackLyricsNotInFile => 'No lyrics found in this file';
@override
String get trackFetchOnlineLyrics => 'Fetch from Online';
@override @override
String get trackLyricsTimeout => 'Request timed out. Try again later.'; String get trackLyricsTimeout => 'Request timed out. Try again later.';
@@ -1261,7 +1306,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1868,17 @@ class AppLocalizationsZh extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1834,6 +1890,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1902,6 +1961,24 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1911,6 +1988,18 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -1997,7 +2086,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Browse the Repo tab to discover useful extensions';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -2195,6 +2284,30 @@ class AppLocalizationsZh extends AppLocalizations {
String get trackReEnrichOnlineSubtitle => String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file'; 'Search metadata online and embed into file';
@override
String get trackReEnrichFieldsTitle => 'Fields to update';
@override
String get trackReEnrichFieldCover => 'Cover Art';
@override
String get trackReEnrichFieldLyrics => 'Lyrics';
@override
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
@override
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
@override
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
@override
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
@override
String get trackReEnrichSelectAll => 'Select All';
@override @override
String get trackEditMetadata => 'Edit Metadata'; String get trackEditMetadata => 'Edit Metadata';
@@ -2609,10 +2722,6 @@ class AppLocalizationsZh extends AppLocalizations {
String get lyricsProvidersDiscardContent => String get lyricsProvidersDiscardContent =>
'You have unsaved changes that will be lost.'; 'You have unsaved changes that will be lost.';
@override
String get lyricsProviderSpotifyApiDesc =>
'Spotify-sourced synced lyrics via community API';
@override @override
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
@@ -3092,6 +3201,194 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
/// The translations for Chinese, as used in China (`zh_CN`). /// The translations for Chinese, as used in China (`zh_CN`).
+456 -11
View File
@@ -17,7 +17,7 @@
"@navSettings": { "@navSettings": {
"description": "Bottom navigation - Settings tab" "description": "Bottom navigation - Settings tab"
}, },
"navStore": "Store", "navStore": "Repo",
"@navStore": { "@navStore": {
"description": "Bottom navigation - Extension store tab" "description": "Bottom navigation - Extension store tab"
}, },
@@ -25,7 +25,7 @@
"@homeTitle": { "@homeTitle": {
"description": "Home screen title" "description": "Home screen title"
}, },
"homeSubtitle": "Paste a Spotify link or search by name", "homeSubtitle": "Paste a supported URL or search by name",
"@homeSubtitle": { "@homeSubtitle": {
"description": "Subtitle shown below search box" "description": "Subtitle shown below search box"
}, },
@@ -89,6 +89,14 @@
"@downloadFilenameFormat": { "@downloadFilenameFormat": {
"description": "Setting for output filename pattern" "description": "Setting for output filename pattern"
}, },
"downloadSingleFilenameFormat": "Single Filename Format",
"@downloadSingleFilenameFormat": {
"description": "Setting for output filename pattern for singles/EPs"
},
"downloadSingleFilenameFormatDescription": "Filename pattern for singles and EPs. Uses the same tags as the album format.",
"@downloadSingleFilenameFormatDescription": {
"description": "Subtitle description for single filename format setting"
},
"downloadFolderOrganization": "Folder Organization", "downloadFolderOrganization": "Folder Organization",
"@downloadFolderOrganization": { "@downloadFolderOrganization": {
"description": "Setting for folder structure" "description": "Setting for folder structure"
@@ -190,6 +198,42 @@
"@optionsMaxQualityCoverSubtitle": { "@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover" "description": "Subtitle for max quality cover"
}, },
"optionsReplayGain": "ReplayGain",
"@optionsReplayGain": {
"description": "Title for ReplayGain setting toggle"
},
"optionsReplayGainSubtitleOn": "Scan loudness and embed ReplayGain tags (EBU R128)",
"@optionsReplayGainSubtitleOn": {
"description": "Subtitle when ReplayGain is enabled"
},
"optionsReplayGainSubtitleOff": "Disabled: no loudness normalization tags",
"@optionsReplayGainSubtitleOff": {
"description": "Subtitle when ReplayGain is disabled"
},
"optionsArtistTagMode": "Artist Tag Mode",
"@optionsArtistTagMode": {
"description": "Setting title for how artist metadata is written into files"
},
"optionsArtistTagModeDescription": "Choose how multiple artists are written into embedded tags.",
"@optionsArtistTagModeDescription": {
"description": "Bottom-sheet description for artist tag mode setting"
},
"optionsArtistTagModeJoined": "Single joined value",
"@optionsArtistTagModeJoined": {
"description": "Artist tag mode option that joins multiple artists into one value"
},
"optionsArtistTagModeJoinedSubtitle": "Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.",
"@optionsArtistTagModeJoinedSubtitle": {
"description": "Subtitle for joined artist tag mode"
},
"optionsArtistTagModeSplitVorbis": "Split tags for FLAC/Opus",
"@optionsArtistTagModeSplitVorbis": {
"description": "Artist tag mode option that writes repeated ARTIST tags for Vorbis formats"
},
"optionsArtistTagModeSplitVorbisSubtitle": "Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.",
"@optionsArtistTagModeSplitVorbisSubtitle": {
"description": "Subtitle for split Vorbis artist tag mode"
},
"optionsConcurrentDownloads": "Concurrent Downloads", "optionsConcurrentDownloads": "Concurrent Downloads",
"@optionsConcurrentDownloads": { "@optionsConcurrentDownloads": {
"description": "Number of parallel downloads" "description": "Number of parallel downloads"
@@ -211,11 +255,11 @@
"@optionsConcurrentWarning": { "@optionsConcurrentWarning": {
"description": "Warning about rate limits" "description": "Warning about rate limits"
}, },
"optionsExtensionStore": "Extension Store", "optionsExtensionStore": "Extension Repo",
"@optionsExtensionStore": { "@optionsExtensionStore": {
"description": "Show/hide store tab" "description": "Show/hide store tab"
}, },
"optionsExtensionStoreSubtitle": "Show Store tab in navigation", "optionsExtensionStoreSubtitle": "Show Repo tab in navigation",
"@optionsExtensionStoreSubtitle": { "@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle" "description": "Subtitle for extension store toggle"
}, },
@@ -318,7 +362,7 @@
"@extensionsUninstall": { "@extensionsUninstall": {
"description": "Uninstall extension button" "description": "Uninstall extension button"
}, },
"storeTitle": "Extension Store", "storeTitle": "Extension Repo",
"@storeTitle": { "@storeTitle": {
"description": "Store screen title" "description": "Store screen title"
}, },
@@ -1519,6 +1563,14 @@
"@trackLyricsNotAvailable": { "@trackLyricsNotAvailable": {
"description": "Message when lyrics not found" "description": "Message when lyrics not found"
}, },
"trackLyricsNotInFile": "No lyrics found in this file",
"@trackLyricsNotInFile": {
"description": "Message when no embedded lyrics in audio file"
},
"trackFetchOnlineLyrics": "Fetch from Online",
"@trackFetchOnlineLyrics": {
"description": "Action - fetch lyrics from online providers"
},
"trackLyricsTimeout": "Request timed out. Try again later.", "trackLyricsTimeout": "Request timed out. Try again later.",
"@trackLyricsTimeout": { "@trackLyricsTimeout": {
"description": "Message when lyrics request times out" "description": "Message when lyrics request times out"
@@ -1654,7 +1706,7 @@
"@storeNewRepoUrlLabel": { "@storeNewRepoUrlLabel": {
"description": "Label for the new repository URL field inside the dialog" "description": "Label for the new repository URL field inside the dialog"
}, },
"storeLoadError": "Failed to load store", "storeLoadError": "Failed to load repository",
"@storeLoadError": { "@storeLoadError": {
"description": "Error heading when the store cannot be loaded" "description": "Error heading when the store cannot be loaded"
}, },
@@ -2399,6 +2451,15 @@
} }
} }
}, },
"libraryFilesUnit": "{count, plural, =1{file} other{files}}",
"@libraryFilesUnit": {
"description": "Unit label for files count during library scanning",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}", "libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": { "@libraryLastScanned": {
"description": "Last scan time display", "description": "Last scan time display",
@@ -2416,6 +2477,10 @@
"@libraryScanning": { "@libraryScanning": {
"description": "Status during scan" "description": "Status during scan"
}, },
"libraryScanFinalizing": "Finalizing library...",
"@libraryScanFinalizing": {
"description": "Status shown after file scanning finishes but library persistence is still running"
},
"libraryScanProgress": "{progress}% of {total} files", "libraryScanProgress": "{progress}% of {total} files",
"@libraryScanProgress": { "@libraryScanProgress": {
"description": "Scan progress display", "description": "Scan progress display",
@@ -2513,6 +2578,30 @@
"@libraryFilterFormat": { "@libraryFilterFormat": {
"description": "Filter section - file format" "description": "Filter section - file format"
}, },
"libraryFilterMetadata": "Metadata",
"@libraryFilterMetadata": {
"description": "Filter section - metadata completeness"
},
"libraryFilterMetadataComplete": "Complete metadata",
"@libraryFilterMetadataComplete": {
"description": "Filter option - items with complete metadata"
},
"libraryFilterMetadataMissingAny": "Missing any metadata",
"@libraryFilterMetadataMissingAny": {
"description": "Filter option - items missing any tracked metadata field"
},
"libraryFilterMetadataMissingYear": "Missing year",
"@libraryFilterMetadataMissingYear": {
"description": "Filter option - items missing release year/date"
},
"libraryFilterMetadataMissingGenre": "Missing genre",
"@libraryFilterMetadataMissingGenre": {
"description": "Filter option - items missing genre"
},
"libraryFilterMetadataMissingAlbumArtist": "Missing album artist",
"@libraryFilterMetadataMissingAlbumArtist": {
"description": "Filter option - items missing album artist"
},
"libraryFilterSort": "Sort", "libraryFilterSort": "Sort",
"@libraryFilterSort": { "@libraryFilterSort": {
"description": "Filter section - sort order" "description": "Filter section - sort order"
@@ -2525,6 +2614,22 @@
"@libraryFilterSortOldest": { "@libraryFilterSortOldest": {
"description": "Sort option - oldest first" "description": "Sort option - oldest first"
}, },
"libraryFilterSortAlbumAsc": "Album (A-Z)",
"@libraryFilterSortAlbumAsc": {
"description": "Sort option - album ascending"
},
"libraryFilterSortAlbumDesc": "Album (Z-A)",
"@libraryFilterSortAlbumDesc": {
"description": "Sort option - album descending"
},
"libraryFilterSortGenreAsc": "Genre (A-Z)",
"@libraryFilterSortGenreAsc": {
"description": "Sort option - genre ascending"
},
"libraryFilterSortGenreDesc": "Genre (Z-A)",
"@libraryFilterSortGenreDesc": {
"description": "Sort option - genre descending"
},
"timeJustNow": "Just now", "timeJustNow": "Just now",
"@timeJustNow": { "@timeJustNow": {
"description": "Relative time - less than a minute ago" "description": "Relative time - less than a minute ago"
@@ -2611,7 +2716,7 @@
"@tutorialExtensionsDesc": { "@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description" "description": "Tutorial extensions page description"
}, },
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", "tutorialExtensionsTip1": "Browse the Repo tab to discover useful extensions",
"@tutorialExtensionsTip1": { "@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1" "description": "Tutorial extensions tip 1"
}, },
@@ -2877,6 +2982,38 @@
"@trackReEnrichOnlineSubtitle": { "@trackReEnrichOnlineSubtitle": {
"description": "Subtitle for re-enrich metadata action for local items" "description": "Subtitle for re-enrich metadata action for local items"
}, },
"trackReEnrichFieldsTitle": "Fields to update",
"@trackReEnrichFieldsTitle": {
"description": "Section title for field selection in re-enrich dialog"
},
"trackReEnrichFieldCover": "Cover Art",
"@trackReEnrichFieldCover": {
"description": "Checkbox label for cover art field in re-enrich"
},
"trackReEnrichFieldLyrics": "Lyrics",
"@trackReEnrichFieldLyrics": {
"description": "Checkbox label for lyrics field in re-enrich"
},
"trackReEnrichFieldBasicTags": "Album, Album Artist",
"@trackReEnrichFieldBasicTags": {
"description": "Checkbox label for basic tags in re-enrich (title/artist are never overwritten)"
},
"trackReEnrichFieldTrackInfo": "Track & Disc Number",
"@trackReEnrichFieldTrackInfo": {
"description": "Checkbox label for track info in re-enrich"
},
"trackReEnrichFieldReleaseInfo": "Date & ISRC",
"@trackReEnrichFieldReleaseInfo": {
"description": "Checkbox label for release info in re-enrich"
},
"trackReEnrichFieldExtra": "Genre, Label, Copyright",
"@trackReEnrichFieldExtra": {
"description": "Checkbox label for extra metadata in re-enrich"
},
"trackReEnrichSelectAll": "Select All",
"@trackReEnrichSelectAll": {
"description": "Select all fields checkbox in re-enrich"
},
"trackEditMetadata": "Edit Metadata", "trackEditMetadata": "Edit Metadata",
"@trackEditMetadata": { "@trackEditMetadata": {
"description": "Menu action - edit embedded metadata" "description": "Menu action - edit embedded metadata"
@@ -3474,10 +3611,6 @@
"@lyricsProvidersDiscardContent": { "@lyricsProvidersDiscardContent": {
"description": "Body text of the discard-changes dialog on lyrics provider page" "description": "Body text of the discard-changes dialog on lyrics provider page"
}, },
"lyricsProviderSpotifyApiDesc": "Spotify-sourced synced lyrics via community API",
"@lyricsProviderSpotifyApiDesc": {
"description": "Description for Spotify Lyrics API provider"
},
"lyricsProviderLrclibDesc": "Open-source synced lyrics database", "lyricsProviderLrclibDesc": "Open-source synced lyrics database",
"@lyricsProviderLrclibDesc": { "@lyricsProviderLrclibDesc": {
"description": "Description for LRCLIB provider" "description": "Description for LRCLIB provider"
@@ -4063,5 +4196,317 @@
"audioAnalysisSamples": "Samples", "audioAnalysisSamples": "Samples",
"@audioAnalysisSamples": { "@audioAnalysisSamples": {
"description": "Total samples metric label" "description": "Total samples metric label"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
"placeholders": {
"providerName": {
"type": "String"
}
}
},
"extensionsHomeFeedProvider": "Home Feed Provider",
"@extensionsHomeFeedProvider": {
"description": "Extensions page - label for home feed provider selector"
},
"extensionsHomeFeedDescription": "Choose which extension provides the home feed on the main screen",
"@extensionsHomeFeedDescription": {
"description": "Extensions page - description for home feed provider picker"
},
"extensionsHomeFeedAuto": "Auto",
"@extensionsHomeFeedAuto": {
"description": "Extensions page - home feed provider option: auto"
},
"extensionsHomeFeedAutoSubtitle": "Automatically select the best available",
"@extensionsHomeFeedAutoSubtitle": {
"description": "Extensions page - subtitle for auto home feed option"
},
"extensionsHomeFeedUse": "Use {extensionName} home feed",
"@extensionsHomeFeedUse": {
"description": "Extensions page - subtitle for a specific extension home feed option",
"placeholders": {
"extensionName": {
"type": "String"
}
}
},
"extensionsNoHomeFeedExtensions": "No extensions with home feed",
"@extensionsNoHomeFeedExtensions": {
"description": "Extensions page - shown when no installed extension has home feed"
},
"sortAlphaAsc": "A-Z",
"@sortAlphaAsc": {
"description": "Sort option - alphabetical ascending"
},
"sortAlphaDesc": "Z-A",
"@sortAlphaDesc": {
"description": "Sort option - alphabetical descending"
},
"cancelDownloadTitle": "Cancel download?",
"@cancelDownloadTitle": {
"description": "Dialog title when confirming cancellation of an active download"
},
"cancelDownloadContent": "This will cancel the active download for \"{trackName}\".",
"@cancelDownloadContent": {
"description": "Dialog body when confirming cancellation of an active download",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"cancelDownloadKeep": "Keep",
"@cancelDownloadKeep": {
"description": "Dialog button - keep the active download (do not cancel)"
},
"metadataSaveFailedFfmpeg": "Failed to save metadata via FFmpeg",
"@metadataSaveFailedFfmpeg": {
"description": "Snackbar error when FFmpeg fails to write metadata"
},
"metadataSaveFailedStorage": "Failed to write metadata back to storage",
"@metadataSaveFailedStorage": {
"description": "Snackbar error when writing metadata file back to storage fails"
},
"snackbarFolderPickerFailed": "Failed to open folder picker: {error}",
"@snackbarFolderPickerFailed": {
"description": "Snackbar shown when folder picker fails to open",
"placeholders": {
"error": {
"type": "String"
}
}
},
"errorLoadAlbum": "Failed to load album",
"@errorLoadAlbum": {
"description": "Error state shown when album fails to load"
},
"errorLoadPlaylist": "Failed to load playlist",
"@errorLoadPlaylist": {
"description": "Error state shown when playlist fails to load"
},
"errorLoadArtist": "Failed to load artist",
"@errorLoadArtist": {
"description": "Error state shown when artist fails to load"
},
"notifChannelDownloadName": "Download Progress",
"@notifChannelDownloadName": {
"description": "Android notification channel name for download progress"
},
"notifChannelDownloadDesc": "Shows download progress for tracks",
"@notifChannelDownloadDesc": {
"description": "Android notification channel description for download progress"
},
"notifChannelLibraryScanName": "Library Scan",
"@notifChannelLibraryScanName": {
"description": "Android notification channel name for library scan"
},
"notifChannelLibraryScanDesc": "Shows local library scan progress",
"@notifChannelLibraryScanDesc": {
"description": "Android notification channel description for library scan"
},
"notifDownloadingTrack": "Downloading {trackName}",
"@notifDownloadingTrack": {
"description": "Notification title while downloading a track",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"notifFinalizingTrack": "Finalizing {trackName}",
"@notifFinalizingTrack": {
"description": "Notification title while finalizing (embedding metadata) a track",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"notifEmbeddingMetadata": "Embedding metadata...",
"@notifEmbeddingMetadata": {
"description": "Notification body while embedding metadata into a downloaded track"
},
"notifAlreadyInLibraryCount": "Already in Library ({completed}/{total})",
"@notifAlreadyInLibraryCount": {
"description": "Notification title when track is already in library, with count",
"placeholders": {
"completed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"notifAlreadyInLibrary": "Already in Library",
"@notifAlreadyInLibrary": {
"description": "Notification title when track is already in library"
},
"notifDownloadCompleteCount": "Download Complete ({completed}/{total})",
"@notifDownloadCompleteCount": {
"description": "Notification title when download is complete, with count",
"placeholders": {
"completed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"notifDownloadComplete": "Download Complete",
"@notifDownloadComplete": {
"description": "Notification title when a single download is complete"
},
"notifDownloadsFinished": "Downloads Finished ({completed} done, {failed} failed)",
"@notifDownloadsFinished": {
"description": "Notification title when queue finishes with some failures",
"placeholders": {
"completed": {
"type": "int"
},
"failed": {
"type": "int"
}
}
},
"notifAllDownloadsComplete": "All Downloads Complete",
"@notifAllDownloadsComplete": {
"description": "Notification title when all downloads finish successfully"
},
"notifTracksDownloadedSuccess": "{count} tracks downloaded successfully",
"@notifTracksDownloadedSuccess": {
"description": "Notification body for queue complete - how many tracks were downloaded",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifScanningLibrary": "Scanning local library",
"@notifScanningLibrary": {
"description": "Notification title while scanning local library"
},
"notifLibraryScanProgressWithTotal": "{scanned}/{total} files • {percentage}%",
"@notifLibraryScanProgressWithTotal": {
"description": "Notification body for library scan progress when total is known",
"placeholders": {
"scanned": {
"type": "int"
},
"total": {
"type": "int"
},
"percentage": {
"type": "int"
}
}
},
"notifLibraryScanProgressNoTotal": "{scanned} files scanned • {percentage}%",
"@notifLibraryScanProgressNoTotal": {
"description": "Notification body for library scan progress when total is unknown",
"placeholders": {
"scanned": {
"type": "int"
},
"percentage": {
"type": "int"
}
}
},
"notifLibraryScanComplete": "Library scan complete",
"@notifLibraryScanComplete": {
"description": "Notification title when library scan finishes"
},
"notifLibraryScanCompleteBody": "{count} tracks indexed",
"@notifLibraryScanCompleteBody": {
"description": "Notification body for library scan complete - number of indexed tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifLibraryScanExcluded": "{count} excluded",
"@notifLibraryScanExcluded": {
"description": "Library scan complete suffix - excluded track count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifLibraryScanErrors": "{count} errors",
"@notifLibraryScanErrors": {
"description": "Library scan complete suffix - error count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifLibraryScanFailed": "Library scan failed",
"@notifLibraryScanFailed": {
"description": "Notification title when library scan fails"
},
"notifLibraryScanCancelled": "Library scan cancelled",
"@notifLibraryScanCancelled": {
"description": "Notification title when library scan is cancelled by the user"
},
"notifLibraryScanStopped": "Scan stopped before completion.",
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
"version": {
"type": "String"
}
}
},
"notifUpdateProgress": "{received} / {total} MB • {percentage}%",
"@notifUpdateProgress": {
"description": "Notification body showing update download progress",
"placeholders": {
"received": {
"type": "String"
},
"total": {
"type": "String"
},
"percentage": {
"type": "int"
}
}
},
"notifUpdateReady": "Update Ready",
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
"version": {
"type": "String"
}
}
},
"notifUpdateFailed": "Update Failed",
"@notifUpdateFailed": {
"description": "Notification title when app update download fails"
},
"notifUpdateFailedBody": "Could not download update. Try again later.",
"@notifUpdateFailedBody": {
"description": "Notification body when app update download fails"
} }
} }
+10 -6
View File
@@ -17,7 +17,7 @@
"@navSettings": { "@navSettings": {
"description": "Bottom navigation - Settings tab" "description": "Bottom navigation - Settings tab"
}, },
"navStore": "Toko", "navStore": "Repo",
"@navStore": { "@navStore": {
"description": "Bottom navigation - Extension store tab" "description": "Bottom navigation - Extension store tab"
}, },
@@ -25,7 +25,7 @@
"@homeTitle": { "@homeTitle": {
"description": "Home screen title" "description": "Home screen title"
}, },
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama", "homeSubtitle": "Tempel URL yang didukung atau cari berdasarkan nama",
"@homeSubtitle": { "@homeSubtitle": {
"description": "Subtitle shown below search box" "description": "Subtitle shown below search box"
}, },
@@ -211,11 +211,11 @@
"@optionsConcurrentWarning": { "@optionsConcurrentWarning": {
"description": "Warning about rate limits" "description": "Warning about rate limits"
}, },
"optionsExtensionStore": "Toko Ekstensi", "optionsExtensionStore": "Repo Ekstensi",
"@optionsExtensionStore": { "@optionsExtensionStore": {
"description": "Show/hide store tab" "description": "Show/hide store tab"
}, },
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi", "optionsExtensionStoreSubtitle": "Tampilkan tab Repo di navigasi",
"@optionsExtensionStoreSubtitle": { "@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle" "description": "Subtitle for extension store toggle"
}, },
@@ -318,10 +318,14 @@
"@extensionsUninstall": { "@extensionsUninstall": {
"description": "Uninstall extension button" "description": "Uninstall extension button"
}, },
"storeTitle": "Toko Ekstensi", "storeTitle": "Repo Ekstensi",
"@storeTitle": { "@storeTitle": {
"description": "Store screen title" "description": "Store screen title"
}, },
"storeLoadError": "Gagal memuat repo",
"@storeLoadError": {
"description": "Error heading when the store cannot be loaded"
},
"storeSearch": "Cari ekstensi...", "storeSearch": "Cari ekstensi...",
"@storeSearch": { "@storeSearch": {
"description": "Store search placeholder" "description": "Store search placeholder"
@@ -2459,7 +2463,7 @@
"@tutorialExtensionsDesc": { "@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description" "description": "Tutorial extensions page description"
}, },
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", "tutorialExtensionsTip1": "Buka tab Repo untuk menemukan ekstensi yang berguna",
"@tutorialExtensionsTip1": { "@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1" "description": "Tutorial extensions tip 1"
}, },
-6
View File
@@ -82,7 +82,6 @@ class _RuntimeProfile {
}); });
} }
/// 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});
final Widget child; final Widget child;
@@ -170,10 +169,8 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
const Duration(milliseconds: 1600), const Duration(milliseconds: 1600),
() { () {
ref.read(localLibraryProvider); ref.read(localLibraryProvider);
// Trigger auto-scan after initial warmup on first app launch.
if (!_autoScanTriggeredOnLaunch) { if (!_autoScanTriggeredOnLaunch) {
_autoScanTriggeredOnLaunch = true; _autoScanTriggeredOnLaunch = true;
// Give the provider a moment to load existing data before scanning.
Future.delayed(const Duration(milliseconds: 500), () { Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) _maybeAutoScanLocalLibrary(); if (mounted) _maybeAutoScanLocalLibrary();
}); });
@@ -182,8 +179,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
); );
} }
/// Checks whether an automatic incremental scan should be triggered based on
/// the user's auto-scan preference and the time since the last scan.
Future<void> _maybeAutoScanLocalLibrary() async { Future<void> _maybeAutoScanLocalLibrary() async {
if (!mounted) return; if (!mounted) return;
@@ -204,7 +199,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
switch (settings.localLibraryAutoScan) { switch (settings.localLibraryAutoScan) {
case 'on_open': case 'on_open':
// Cooldown of 10 minutes to prevent rapid re-scans.
if (elapsed.inMinutes < 10) return; if (elapsed.inMinutes < 10) return;
break; break;
case 'daily': case 'daily':
+7 -9
View File
@@ -12,13 +12,7 @@ enum DownloadStatus {
skipped, skipped,
} }
enum DownloadErrorType { enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
unknown,
notFound,
rateLimit,
network,
permission,
}
@JsonSerializable() @JsonSerializable()
class DownloadItem { class DownloadItem {
@@ -28,7 +22,8 @@ class DownloadItem {
final DownloadStatus status; final DownloadStatus status;
final double progress; final double progress;
final double speedMBps; final double speedMBps;
final int bytesReceived; // Bytes downloaded so far (for unknown size downloads) final int bytesReceived; // Bytes downloaded so far
final int bytesTotal; // Total bytes when the server provides content length
final String? filePath; final String? filePath;
final String? error; final String? error;
final DownloadErrorType? errorType; final DownloadErrorType? errorType;
@@ -44,6 +39,7 @@ class DownloadItem {
this.progress = 0.0, this.progress = 0.0,
this.speedMBps = 0.0, this.speedMBps = 0.0,
this.bytesReceived = 0, this.bytesReceived = 0,
this.bytesTotal = 0,
this.filePath, this.filePath,
this.error, this.error,
this.errorType, this.errorType,
@@ -60,6 +56,7 @@ class DownloadItem {
double? progress, double? progress,
double? speedMBps, double? speedMBps,
int? bytesReceived, int? bytesReceived,
int? bytesTotal,
String? filePath, String? filePath,
String? error, String? error,
DownloadErrorType? errorType, DownloadErrorType? errorType,
@@ -75,6 +72,7 @@ class DownloadItem {
progress: progress ?? this.progress, progress: progress ?? this.progress,
speedMBps: speedMBps ?? this.speedMBps, speedMBps: speedMBps ?? this.speedMBps,
bytesReceived: bytesReceived ?? this.bytesReceived, bytesReceived: bytesReceived ?? this.bytesReceived,
bytesTotal: bytesTotal ?? this.bytesTotal,
filePath: filePath ?? this.filePath, filePath: filePath ?? this.filePath,
error: error ?? this.error, error: error ?? this.error,
errorType: errorType ?? this.errorType, errorType: errorType ?? this.errorType,
@@ -86,7 +84,7 @@ class DownloadItem {
String get errorMessage { String get errorMessage {
if (error == null) return ''; if (error == null) return '';
switch (errorType) { switch (errorType) {
case DownloadErrorType.notFound: case DownloadErrorType.notFound:
return 'Song not found on any service'; return 'Song not found on any service';
+2
View File
@@ -16,6 +16,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
progress: (json['progress'] as num?)?.toDouble() ?? 0.0, progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0, speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0, bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
bytesTotal: (json['bytesTotal'] as num?)?.toInt() ?? 0,
filePath: json['filePath'] as String?, filePath: json['filePath'] as String?,
error: json['error'] as String?, error: json['error'] as String?,
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']), errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
@@ -33,6 +34,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'progress': instance.progress, 'progress': instance.progress,
'speedMBps': instance.speedMBps, 'speedMBps': instance.speedMBps,
'bytesReceived': instance.bytesReceived, 'bytesReceived': instance.bytesReceived,
'bytesTotal': instance.bytesTotal,
'filePath': instance.filePath, 'filePath': instance.filePath,
'error': instance.error, 'error': instance.error,
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType], 'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
+14 -18
View File
@@ -1,4 +1,5 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
part 'settings.g.dart'; part 'settings.g.dart';
@@ -12,7 +13,10 @@ class AppSettings {
final String downloadTreeUri; // SAF persistable tree URI final String downloadTreeUri; // SAF persistable tree URI
final bool autoFallback; final bool autoFallback;
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
final String
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
final bool embedLyrics; final bool embedLyrics;
final bool embedReplayGain; // Calculate and embed ReplayGain tags
final bool maxQualityCover; final bool maxQualityCover;
final bool isFirstLaunch; final bool isFirstLaunch;
final int concurrentDownloads; final int concurrentDownloads;
@@ -27,15 +31,12 @@ class AppSettings {
final String historyViewMode; final String historyViewMode;
final String historyFilterMode; final String historyFilterMode;
final bool askQualityBeforeDownload; final bool askQualityBeforeDownload;
final String spotifyClientId;
final String spotifyClientSecret;
final bool useCustomSpotifyCredentials;
final String metadataSource;
final bool enableLogging; final bool enableLogging;
final bool useExtensionProviders; final bool useExtensionProviders;
final String? searchProvider; final String? searchProvider;
final String? homeFeedProvider; final String? homeFeedProvider;
final bool separateSingles; final bool separateSingles;
final String singleFilenameFormat;
final String albumFolderStructure; final String albumFolderStructure;
final bool showExtensionStore; final bool showExtensionStore;
final String locale; final String locale;
@@ -88,7 +89,9 @@ class AppSettings {
this.downloadTreeUri = '', this.downloadTreeUri = '',
this.autoFallback = true, this.autoFallback = true,
this.embedMetadata = true, this.embedMetadata = true,
this.artistTagMode = artistTagModeJoined,
this.embedLyrics = true, this.embedLyrics = true,
this.embedReplayGain = false,
this.maxQualityCover = true, this.maxQualityCover = true,
this.isFirstLaunch = true, this.isFirstLaunch = true,
this.concurrentDownloads = 1, this.concurrentDownloads = 1,
@@ -103,15 +106,12 @@ class AppSettings {
this.historyViewMode = 'grid', this.historyViewMode = 'grid',
this.historyFilterMode = 'all', this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true, this.askQualityBeforeDownload = true,
this.spotifyClientId = '',
this.spotifyClientSecret = '',
this.useCustomSpotifyCredentials = false,
this.metadataSource = 'deezer',
this.enableLogging = false, this.enableLogging = false,
this.useExtensionProviders = true, this.useExtensionProviders = true,
this.searchProvider, this.searchProvider,
this.homeFeedProvider, this.homeFeedProvider,
this.separateSingles = false, this.separateSingles = false,
this.singleFilenameFormat = '{title} - {artist}',
this.albumFolderStructure = 'artist_album', this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true, this.showExtensionStore = true,
this.locale = 'system', this.locale = 'system',
@@ -130,7 +130,6 @@ class AppSettings {
this.hasCompletedTutorial = false, this.hasCompletedTutorial = false,
this.lyricsProviders = const [ this.lyricsProviders = const [
'lrclib', 'lrclib',
'spotify_api',
'musixmatch', 'musixmatch',
'netease', 'netease',
'apple_music', 'apple_music',
@@ -152,7 +151,9 @@ class AppSettings {
String? downloadTreeUri, String? downloadTreeUri,
bool? autoFallback, bool? autoFallback,
bool? embedMetadata, bool? embedMetadata,
String? artistTagMode,
bool? embedLyrics, bool? embedLyrics,
bool? embedReplayGain,
bool? maxQualityCover, bool? maxQualityCover,
bool? isFirstLaunch, bool? isFirstLaunch,
int? concurrentDownloads, int? concurrentDownloads,
@@ -167,10 +168,6 @@ class AppSettings {
String? historyViewMode, String? historyViewMode,
String? historyFilterMode, String? historyFilterMode,
bool? askQualityBeforeDownload, bool? askQualityBeforeDownload,
String? spotifyClientId,
String? spotifyClientSecret,
bool? useCustomSpotifyCredentials,
String? metadataSource,
bool? enableLogging, bool? enableLogging,
bool? useExtensionProviders, bool? useExtensionProviders,
String? searchProvider, String? searchProvider,
@@ -178,6 +175,7 @@ class AppSettings {
String? homeFeedProvider, String? homeFeedProvider,
bool clearHomeFeedProvider = false, bool clearHomeFeedProvider = false,
bool? separateSingles, bool? separateSingles,
String? singleFilenameFormat,
String? albumFolderStructure, String? albumFolderStructure,
bool? showExtensionStore, bool? showExtensionStore,
String? locale, String? locale,
@@ -210,7 +208,9 @@ class AppSettings {
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
autoFallback: autoFallback ?? this.autoFallback, autoFallback: autoFallback ?? this.autoFallback,
embedMetadata: embedMetadata ?? this.embedMetadata, embedMetadata: embedMetadata ?? this.embedMetadata,
artistTagMode: artistTagMode ?? this.artistTagMode,
embedLyrics: embedLyrics ?? this.embedLyrics, embedLyrics: embedLyrics ?? this.embedLyrics,
embedReplayGain: embedReplayGain ?? this.embedReplayGain,
maxQualityCover: maxQualityCover ?? this.maxQualityCover, maxQualityCover: maxQualityCover ?? this.maxQualityCover,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads, concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
@@ -229,11 +229,6 @@ class AppSettings {
historyFilterMode: historyFilterMode ?? this.historyFilterMode, historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload:
askQualityBeforeDownload ?? this.askQualityBeforeDownload, askQualityBeforeDownload ?? this.askQualityBeforeDownload,
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
useCustomSpotifyCredentials:
useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging, enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders: useExtensionProviders:
useExtensionProviders ?? this.useExtensionProviders, useExtensionProviders ?? this.useExtensionProviders,
@@ -244,6 +239,7 @@ class AppSettings {
? null ? null
: (homeFeedProvider ?? this.homeFeedProvider), : (homeFeedProvider ?? this.homeFeedProvider),
separateSingles: separateSingles ?? this.separateSingles, separateSingles: separateSingles ?? this.separateSingles,
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore, showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale, locale: locale ?? this.locale,
+8 -17
View File
@@ -15,7 +15,9 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
downloadTreeUri: json['downloadTreeUri'] as String? ?? '', downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true, autoFallback: json['autoFallback'] as bool? ?? true,
embedMetadata: json['embedMetadata'] as bool? ?? true, embedMetadata: json['embedMetadata'] as bool? ?? true,
artistTagMode: json['artistTagMode'] as String? ?? artistTagModeJoined,
embedLyrics: json['embedLyrics'] as bool? ?? true, embedLyrics: json['embedLyrics'] as bool? ?? true,
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
maxQualityCover: json['maxQualityCover'] as bool? ?? true, maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1, concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
@@ -31,16 +33,13 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
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,
spotifyClientId: json['spotifyClientId'] as String? ?? '',
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials:
json['useCustomSpotifyCredentials'] as bool? ?? false,
metadataSource: json['metadataSource'] as String? ?? 'deezer',
enableLogging: json['enableLogging'] as bool? ?? false, enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?, searchProvider: json['searchProvider'] as String?,
homeFeedProvider: json['homeFeedProvider'] as String?, homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false, separateSingles: json['separateSingles'] as bool? ?? false,
singleFilenameFormat:
json['singleFilenameFormat'] as String? ?? '{title} - {artist}',
albumFolderStructure: albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album', json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true, showExtensionStore: json['showExtensionStore'] as bool? ?? true,
@@ -64,14 +63,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
(json['lyricsProviders'] as List<dynamic>?) (json['lyricsProviders'] as List<dynamic>?)
?.map((e) => e as String) ?.map((e) => e as String)
.toList() ?? .toList() ??
const [ const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'],
'lrclib',
'spotify_api',
'musixmatch',
'netease',
'apple_music',
'qqmusic',
],
lyricsIncludeTranslationNetease: lyricsIncludeTranslationNetease:
json['lyricsIncludeTranslationNetease'] as bool? ?? false, json['lyricsIncludeTranslationNetease'] as bool? ?? false,
lyricsIncludeRomanizationNetease: lyricsIncludeRomanizationNetease:
@@ -93,7 +85,9 @@ Map<String, dynamic> _$AppSettingsToJson(
'downloadTreeUri': instance.downloadTreeUri, 'downloadTreeUri': instance.downloadTreeUri,
'autoFallback': instance.autoFallback, 'autoFallback': instance.autoFallback,
'embedMetadata': instance.embedMetadata, 'embedMetadata': instance.embedMetadata,
'artistTagMode': instance.artistTagMode,
'embedLyrics': instance.embedLyrics, 'embedLyrics': instance.embedLyrics,
'embedReplayGain': instance.embedReplayGain,
'maxQualityCover': instance.maxQualityCover, 'maxQualityCover': instance.maxQualityCover,
'isFirstLaunch': instance.isFirstLaunch, 'isFirstLaunch': instance.isFirstLaunch,
'concurrentDownloads': instance.concurrentDownloads, 'concurrentDownloads': instance.concurrentDownloads,
@@ -109,15 +103,12 @@ Map<String, dynamic> _$AppSettingsToJson(
'historyViewMode': instance.historyViewMode, 'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode, 'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload, 'askQualityBeforeDownload': instance.askQualityBeforeDownload,
'spotifyClientId': instance.spotifyClientId,
'spotifyClientSecret': instance.spotifyClientSecret,
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
'metadataSource': instance.metadataSource,
'enableLogging': instance.enableLogging, 'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders, 'useExtensionProviders': instance.useExtensionProviders,
'searchProvider': instance.searchProvider, 'searchProvider': instance.searchProvider,
'homeFeedProvider': instance.homeFeedProvider, 'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles, 'separateSingles': instance.separateSingles,
'singleFilenameFormat': instance.singleFilenameFormat,
'albumFolderStructure': instance.albumFolderStructure, 'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore, 'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale, 'locale': instance.locale,
+10 -8
View File
@@ -16,12 +16,14 @@ class Track {
final int duration; final int duration;
final int? trackNumber; final int? trackNumber;
final int? discNumber; final int? discNumber;
final int? totalDiscs;
final String? releaseDate; final String? releaseDate;
final String? deezerId; final String? deezerId;
final ServiceAvailability? availability; final ServiceAvailability? availability;
final String? source; final String? source;
final String? albumType; final String? albumType;
final int? totalTracks; final int? totalTracks;
final String? composer;
final String? itemType; final String? itemType;
const Track({ const Track({
@@ -37,38 +39,38 @@ class Track {
required this.duration, required this.duration,
this.trackNumber, this.trackNumber,
this.discNumber, this.discNumber,
this.totalDiscs,
this.releaseDate, this.releaseDate,
this.deezerId, this.deezerId,
this.availability, this.availability,
this.source, this.source,
this.albumType, this.albumType,
this.totalTracks, this.totalTracks,
this.composer,
this.itemType, this.itemType,
}); });
bool get isSingle { bool get isSingle {
switch (albumType?.toLowerCase()) { switch (albumType?.toLowerCase()) {
case 'single': case 'single':
return true;
case 'ep': case 'ep':
final count = totalTracks; return true;
return count == null || count <= 1;
default: default:
return false; return false;
} }
} }
bool get isAlbumItem => itemType == 'album'; bool get isAlbumItem => itemType == 'album';
bool get isPlaylistItem => itemType == 'playlist'; bool get isPlaylistItem => itemType == 'playlist';
bool get isArtistItem => itemType == 'artist'; bool get isArtistItem => itemType == 'artist';
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem; bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json); factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this); Map<String, dynamic> toJson() => _$TrackToJson(this);
bool get isFromExtension => source != null && source!.isNotEmpty; bool get isFromExtension => source != null && source!.isNotEmpty;
} }
+4
View File
@@ -19,6 +19,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
duration: (json['duration'] as num).toInt(), duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(), trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(), discNumber: (json['discNumber'] as num?)?.toInt(),
totalDiscs: (json['totalDiscs'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?, releaseDate: json['releaseDate'] as String?,
deezerId: json['deezerId'] as String?, deezerId: json['deezerId'] as String?,
availability: json['availability'] == null availability: json['availability'] == null
@@ -29,6 +30,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
source: json['source'] as String?, source: json['source'] as String?,
albumType: json['albumType'] as String?, albumType: json['albumType'] as String?,
totalTracks: (json['totalTracks'] as num?)?.toInt(), totalTracks: (json['totalTracks'] as num?)?.toInt(),
composer: json['composer'] as String?,
itemType: json['itemType'] as String?, itemType: json['itemType'] as String?,
); );
@@ -45,12 +47,14 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'duration': instance.duration, 'duration': instance.duration,
'trackNumber': instance.trackNumber, 'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber, 'discNumber': instance.discNumber,
'totalDiscs': instance.totalDiscs,
'releaseDate': instance.releaseDate, 'releaseDate': instance.releaseDate,
'deezerId': instance.deezerId, 'deezerId': instance.deezerId,
'availability': instance.availability, 'availability': instance.availability,
'source': instance.source, 'source': instance.source,
'albumType': instance.albumType, 'albumType': instance.albumType,
'totalTracks': instance.totalTracks, 'totalTracks': instance.totalTracks,
'composer': instance.composer,
'itemType': instance.itemType, 'itemType': instance.itemType,
}; };
File diff suppressed because it is too large Load Diff
+75 -14
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
@@ -19,6 +20,7 @@ class ExploreItem {
final String? providerId; final String? providerId;
final String? albumId; final String? albumId;
final String? albumName; final String? albumName;
final String? releaseDate;
final int durationMs; final int durationMs;
const ExploreItem({ const ExploreItem({
@@ -32,6 +34,7 @@ class ExploreItem {
this.providerId, this.providerId,
this.albumId, this.albumId,
this.albumName, this.albumName,
this.releaseDate,
this.durationMs = 0, this.durationMs = 0,
}); });
@@ -47,6 +50,7 @@ class ExploreItem {
providerId: json['provider_id'] as String?, providerId: json['provider_id'] as String?,
albumId: json['album_id'] as String?, albumId: json['album_id'] as String?,
albumName: json['album_name'] as String?, albumName: json['album_name'] as String?,
releaseDate: json['release_date']?.toString(),
durationMs: json['duration_ms'] as int? ?? 0, durationMs: json['duration_ms'] as int? ?? 0,
); );
} }
@@ -62,6 +66,7 @@ class ExploreItem {
'provider_id': providerId, 'provider_id': providerId,
'album_id': albumId, 'album_id': albumId,
'album_name': albumName, 'album_name': albumName,
'release_date': releaseDate,
'duration_ms': durationMs, 'duration_ms': durationMs,
}; };
} }
@@ -158,6 +163,52 @@ bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
return true; return true;
} }
List<Map<String, Object?>> _normalizeExploreSectionsPayload(
dynamic rawSections,
) {
if (rawSections is! List) return const [];
final sections = <Map<String, Object?>>[];
for (final rawSection in rawSections) {
if (rawSection is! Map) continue;
final section = Map<Object?, Object?>.from(rawSection);
final rawItems = section['items'];
final items = <Map<String, Object?>>[];
if (rawItems is List) {
for (final rawItem in rawItems) {
if (rawItem is! Map) continue;
items.add(Map<String, Object?>.from(rawItem));
}
}
sections.add({
'uri': section['uri']?.toString() ?? '',
'title': section['title']?.toString() ?? '',
'items': items,
});
}
return sections;
}
List<Map<String, Object?>> _decodeExploreCacheSections(String rawCache) {
final decoded = jsonDecode(rawCache);
if (decoded is! Map) return const [];
return _normalizeExploreSectionsPayload(decoded['sections']);
}
String _encodeExploreCacheSections(List<Map<String, Object?>> sections) {
return jsonEncode({'sections': sections});
}
List<ExploreSection> _buildExploreSectionsFromNormalizedPayload(
List<Map<String, Object?>> normalizedSections,
) {
return normalizedSections
.map(
(section) =>
ExploreSection.fromJson(Map<String, dynamic>.from(section)),
)
.toList(growable: false);
}
class ExploreNotifier extends Notifier<ExploreState> { class ExploreNotifier extends Notifier<ExploreState> {
static const _cacheKey = 'explore_home_feed_cache'; static const _cacheKey = 'explore_home_feed_cache';
static const _cacheTsKey = 'explore_home_feed_ts'; static const _cacheTsKey = 'explore_home_feed_ts';
@@ -175,11 +226,13 @@ class ExploreNotifier extends Notifier<ExploreState> {
final cachedTs = prefs.getInt(_cacheTsKey); final cachedTs = prefs.getInt(_cacheTsKey);
if (cached == null || cached.isEmpty) return; if (cached == null || cached.isEmpty) return;
final data = jsonDecode(cached) as Map<String, dynamic>; final normalizedSections = await compute(
final sectionsData = data['sections'] as List<dynamic>? ?? []; _decodeExploreCacheSections,
final sections = sectionsData cached,
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>)) );
.toList(); final sections = _buildExploreSectionsFromNormalizedPayload(
normalizedSections,
);
if (sections.isEmpty) return; if (sections.isEmpty) return;
@@ -198,13 +251,18 @@ class ExploreNotifier extends Notifier<ExploreState> {
} }
} }
Future<void> _saveToCache(List<ExploreSection> sections) async { Future<void> _saveToCache(
List<Map<String, Object?>> normalizedSections,
) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final data = {'sections': sections.map((s) => s.toJson()).toList()}; final encoded = await compute(
await prefs.setString(_cacheKey, jsonEncode(data)); _encodeExploreCacheSections,
normalizedSections,
);
await prefs.setString(_cacheKey, encoded);
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch); await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
_log.d('Saved ${sections.length} explore sections to cache'); _log.d('Saved ${normalizedSections.length} explore sections to cache');
} catch (e) { } catch (e) {
_log.w('Failed to save explore cache: $e'); _log.w('Failed to save explore cache: $e');
} }
@@ -286,10 +344,13 @@ class ExploreNotifier extends Notifier<ExploreState> {
final greeting = result['greeting'] as String?; final greeting = result['greeting'] as String?;
final sectionsData = result['sections'] as List<dynamic>? ?? []; final sectionsData = result['sections'] as List<dynamic>? ?? [];
final normalizedSections = await compute(
final sections = sectionsData _normalizeExploreSectionsPayload,
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>)) sectionsData,
.toList(); );
final sections = _buildExploreSectionsFromNormalizedPayload(
normalizedSections,
);
_log.i('Fetched ${sections.length} sections'); _log.i('Fetched ${sections.length} sections');
@@ -310,7 +371,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
lastFetched: DateTime.now(), lastFetched: DateTime.now(),
); );
_saveToCache(sections); _saveToCache(normalizedSections);
} catch (e, stack) { } catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack); _log.e('Error fetching home feed: $e', e, stack);
state = state.copyWith(isLoading: false, error: e.toString()); state = state.copyWith(isLoading: false, error: e.toString());
+6 -2
View File
@@ -33,6 +33,7 @@ class Extension {
final bool hasDownloadProvider; final bool hasDownloadProvider;
final bool hasLyricsProvider; final bool hasLyricsProvider;
final bool skipMetadataEnrichment; final bool skipMetadataEnrichment;
final bool skipLyrics;
final SearchBehavior? searchBehavior; final SearchBehavior? searchBehavior;
final URLHandler? urlHandler; final URLHandler? urlHandler;
final TrackMatching? trackMatching; final TrackMatching? trackMatching;
@@ -57,6 +58,7 @@ class Extension {
this.hasDownloadProvider = false, this.hasDownloadProvider = false,
this.hasLyricsProvider = false, this.hasLyricsProvider = false,
this.skipMetadataEnrichment = false, this.skipMetadataEnrichment = false,
this.skipLyrics = false,
this.searchBehavior, this.searchBehavior,
this.urlHandler, this.urlHandler,
this.trackMatching, this.trackMatching,
@@ -94,6 +96,7 @@ class Extension {
hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false, hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false,
skipMetadataEnrichment: skipMetadataEnrichment:
json['skip_metadata_enrichment'] as bool? ?? false, json['skip_metadata_enrichment'] as bool? ?? false,
skipLyrics: json['skip_lyrics'] as bool? ?? false,
searchBehavior: json['search_behavior'] != null searchBehavior: json['search_behavior'] != null
? SearchBehavior.fromJson( ? SearchBehavior.fromJson(
json['search_behavior'] as Map<String, dynamic>, json['search_behavior'] as Map<String, dynamic>,
@@ -134,6 +137,7 @@ class Extension {
bool? hasDownloadProvider, bool? hasDownloadProvider,
bool? hasLyricsProvider, bool? hasLyricsProvider,
bool? skipMetadataEnrichment, bool? skipMetadataEnrichment,
bool? skipLyrics,
SearchBehavior? searchBehavior, SearchBehavior? searchBehavior,
URLHandler? urlHandler, URLHandler? urlHandler,
TrackMatching? trackMatching, TrackMatching? trackMatching,
@@ -159,6 +163,7 @@ class Extension {
hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider, hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider,
skipMetadataEnrichment: skipMetadataEnrichment:
skipMetadataEnrichment ?? this.skipMetadataEnrichment, skipMetadataEnrichment ?? this.skipMetadataEnrichment,
skipLyrics: skipLyrics ?? this.skipLyrics,
searchBehavior: searchBehavior ?? this.searchBehavior, searchBehavior: searchBehavior ?? this.searchBehavior,
urlHandler: urlHandler ?? this.urlHandler, urlHandler: urlHandler ?? this.urlHandler,
trackMatching: trackMatching ?? this.trackMatching, trackMatching: trackMatching ?? this.trackMatching,
@@ -662,9 +667,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
if (settings.searchProvider == extensionId) { if (settings.searchProvider == extensionId) {
ref.read(settingsProvider.notifier).setSearchProvider(null); ref.read(settingsProvider.notifier).setSearchProvider(null);
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
_log.d( _log.d(
'Cleared search provider and reset to Deezer because extension $extensionId was disabled', 'Cleared search provider because extension $extensionId was disabled',
); );
} }
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -118,7 +119,7 @@ class UserPlaylistCollection {
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt, updatedAt: updatedAt,
tracks: tracksRaw tracks: tracksRaw
.whereType<Map>() .whereType<Map<Object?, Object?>>()
.map( .map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)), (e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
) )
@@ -127,6 +128,54 @@ class UserPlaylistCollection {
} }
} }
class PlaylistPickerSummary {
final String id;
final String name;
final String? coverImagePath;
final String? previewCover;
final DateTime createdAt;
final DateTime updatedAt;
final int trackCount;
final bool containsAllRequestedTracks;
const PlaylistPickerSummary({
required this.id,
required this.name,
this.coverImagePath,
this.previewCover,
required this.createdAt,
required this.updatedAt,
required this.trackCount,
required this.containsAllRequestedTracks,
});
}
class PlaylistPickerSummaryRequest {
final List<String> trackKeys;
PlaylistPickerSummaryRequest._(this.trackKeys);
factory PlaylistPickerSummaryRequest.fromTracks(Iterable<Track> tracks) {
final keys =
tracks
.map(trackCollectionKey)
.where((key) => key.trim().isNotEmpty)
.toSet()
.toList(growable: false)
..sort();
return PlaylistPickerSummaryRequest._(keys);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PlaylistPickerSummaryRequest &&
listEquals(trackKeys, other.trackKeys);
@override
int get hashCode => Object.hashAll(trackKeys);
}
class LibraryCollectionsState { class LibraryCollectionsState {
final List<CollectionTrackEntry> wishlist; final List<CollectionTrackEntry> wishlist;
final List<CollectionTrackEntry> loved; final List<CollectionTrackEntry> loved;
@@ -233,19 +282,19 @@ class LibraryCollectionsState {
return LibraryCollectionsState( return LibraryCollectionsState(
wishlist: wishlistRaw wishlist: wishlistRaw
.whereType<Map>() .whereType<Map<Object?, Object?>>()
.map( .map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)), (e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
) )
.toList(growable: false), .toList(growable: false),
loved: lovedRaw loved: lovedRaw
.whereType<Map>() .whereType<Map<Object?, Object?>>()
.map( .map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)), (e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
) )
.toList(growable: false), .toList(growable: false),
playlists: playlistsRaw playlists: playlistsRaw
.whereType<Map>() .whereType<Map<Object?, Object?>>()
.map( .map(
(e) => (e) =>
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)), UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
@@ -280,6 +329,10 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance; final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance;
Future<void>? _loadFuture; Future<void>? _loadFuture;
void _invalidatePlaylistPickerSummaries() {
ref.invalidate(libraryPlaylistPickerSummariesProvider);
}
@override @override
LibraryCollectionsState build() { LibraryCollectionsState build() {
_loadFuture = _load(); _loadFuture = _load();
@@ -494,6 +547,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
updatedAt: now.toIso8601String(), updatedAt: now.toIso8601String(),
); );
state = state.copyWith(playlists: [playlist, ...state.playlists]); state = state.copyWith(playlists: [playlist, ...state.playlists]);
_invalidatePlaylistPickerSummaries();
return id; return id;
} }
@@ -513,6 +567,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
_replacePlaylistById(playlistId, (playlist) { _replacePlaylistById(playlistId, (playlist) {
return playlist.copyWith(name: trimmed, updatedAt: now); return playlist.copyWith(name: trimmed, updatedAt: now);
}); });
_invalidatePlaylistPickerSummaries();
} }
Future<void> deletePlaylist(String playlistId) async { Future<void> deletePlaylist(String playlistId) async {
@@ -523,6 +578,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
await _db.deletePlaylist(playlistId); await _db.deletePlaylist(playlistId);
final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex); final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex);
state = state.copyWith(playlists: updatedPlaylists); state = state.copyWith(playlists: updatedPlaylists);
_invalidatePlaylistPickerSummaries();
} }
Future<bool> addTrackToPlaylist(String playlistId, Track track) async { Future<bool> addTrackToPlaylist(String playlistId, Track track) async {
@@ -550,6 +606,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
); );
}); });
if (!changed) return false; if (!changed) return false;
_invalidatePlaylistPickerSummaries();
return true; return true;
} }
@@ -615,6 +672,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
alreadyInPlaylistCount: alreadyInPlaylistCount, alreadyInPlaylistCount: alreadyInPlaylistCount,
); );
} }
_invalidatePlaylistPickerSummaries();
return PlaylistAddBatchResult( return PlaylistAddBatchResult(
addedCount: entriesToAdd.length, addedCount: entriesToAdd.length,
alreadyInPlaylistCount: alreadyInPlaylistCount, alreadyInPlaylistCount: alreadyInPlaylistCount,
@@ -642,6 +700,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
if (nextTracks.length == playlist.tracks.length) return playlist; if (nextTracks.length == playlist.tracks.length) return playlist;
return playlist.copyWith(tracks: nextTracks, updatedAt: now); return playlist.copyWith(tracks: nextTracks, updatedAt: now);
}); });
_invalidatePlaylistPickerSummaries();
} }
Future<Directory> _playlistCoversDir() async { Future<Directory> _playlistCoversDir() async {
@@ -678,6 +737,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
if (playlist.coverImagePath == destPath) return playlist; if (playlist.coverImagePath == destPath) return playlist;
return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now); return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now);
}); });
_invalidatePlaylistPickerSummaries();
} }
Future<void> removePlaylistCover(String playlistId) async { Future<void> removePlaylistCover(String playlistId) async {
@@ -703,6 +763,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
if (playlist.coverImagePath == null) return playlist; if (playlist.coverImagePath == null) return playlist;
return playlist.copyWith(coverImagePath: () => null, updatedAt: now); return playlist.copyWith(coverImagePath: () => null, updatedAt: now);
}); });
_invalidatePlaylistPickerSummaries();
} }
} }
@@ -710,3 +771,27 @@ final libraryCollectionsProvider =
NotifierProvider<LibraryCollectionsNotifier, LibraryCollectionsState>( NotifierProvider<LibraryCollectionsNotifier, LibraryCollectionsState>(
LibraryCollectionsNotifier.new, LibraryCollectionsNotifier.new,
); );
final libraryPlaylistPickerSummariesProvider =
FutureProvider.family<
List<PlaylistPickerSummary>,
PlaylistPickerSummaryRequest
>((ref, request) async {
final db = LibraryCollectionsDatabase.instance;
await db.migrateFromSharedPreferences();
final rows = await db.loadPlaylistPickerSummaries(request.trackKeys);
return rows
.map(
(row) => PlaylistPickerSummary(
id: row.id,
name: row.name,
coverImagePath: row.coverImagePath,
previewCover: row.previewCover,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
trackCount: row.trackCount,
containsAllRequestedTracks: row.containsAllRequestedTracks,
),
)
.toList(growable: false);
});
+123 -19
View File
@@ -20,6 +20,7 @@ final _prefs = SharedPreferences.getInstance();
class LocalLibraryState { class LocalLibraryState {
final List<LocalLibraryItem> items; final List<LocalLibraryItem> items;
final bool isScanning; final bool isScanning;
final bool scanIsFinalizing;
final double scanProgress; final double scanProgress;
final String? scanCurrentFile; final String? scanCurrentFile;
final int scanTotalFiles; final int scanTotalFiles;
@@ -35,6 +36,7 @@ class LocalLibraryState {
LocalLibraryState({ LocalLibraryState({
this.items = const [], this.items = const [],
this.isScanning = false, this.isScanning = false,
this.scanIsFinalizing = false,
this.scanProgress = 0, this.scanProgress = 0,
this.scanCurrentFile, this.scanCurrentFile,
this.scanTotalFiles = 0, this.scanTotalFiles = 0,
@@ -85,6 +87,7 @@ class LocalLibraryState {
LocalLibraryState copyWith({ LocalLibraryState copyWith({
List<LocalLibraryItem>? items, List<LocalLibraryItem>? items,
bool? isScanning, bool? isScanning,
bool? scanIsFinalizing,
double? scanProgress, double? scanProgress,
String? scanCurrentFile, String? scanCurrentFile,
int? scanTotalFiles, int? scanTotalFiles,
@@ -100,6 +103,7 @@ class LocalLibraryState {
return LocalLibraryState( return LocalLibraryState(
items: nextItems, items: nextItems,
isScanning: isScanning ?? this.isScanning, isScanning: isScanning ?? this.isScanning,
scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing,
scanProgress: scanProgress ?? this.scanProgress, scanProgress: scanProgress ?? this.scanProgress,
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile, scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles, scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
@@ -120,7 +124,8 @@ 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(); final NotificationService _notificationService = NotificationService();
static const _progressPollingInterval = Duration(milliseconds: 1200); static const _progressPollingInterval = Duration(milliseconds: 350);
static const _progressStreamBootstrapTimeout = Duration(milliseconds: 900);
Timer? _progressTimer; Timer? _progressTimer;
Timer? _progressStreamBootstrapTimer; Timer? _progressStreamBootstrapTimer;
StreamSubscription<Map<String, dynamic>>? _progressStreamSub; StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
@@ -220,6 +225,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
); );
state = state.copyWith( state = state.copyWith(
isScanning: true, isScanning: true,
scanIsFinalizing: false,
scanProgress: 0, scanProgress: 0,
scanCurrentFile: null, scanCurrentFile: null,
scanTotalFiles: 0, scanTotalFiles: 0,
@@ -297,11 +303,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
? await PlatformBridge.scanSafTree(effectiveFolderPath) ? await PlatformBridge.scanSafTree(effectiveFolderPath)
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath); : await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
if (_scanCancelRequested) { if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(
isScanning: false,
scanIsFinalizing: false,
scanWasCancelled: true,
);
await _showScanCancelledNotification(); await _showScanCancelledNotification();
return; return;
} }
state = state.copyWith(
scanIsFinalizing: true,
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
scanCurrentFile: null,
);
final items = <LocalLibraryItem>[]; final items = <LocalLibraryItem>[];
int skippedDownloads = 0; int skippedDownloads = 0;
for (final json in results) { for (final json in results) {
@@ -334,11 +350,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith( state = state.copyWith(
items: persistedItems, items: persistedItems,
isScanning: false, isScanning: false,
scanIsFinalizing: false,
scanProgress: 100, scanProgress: 100,
lastScannedAt: now, lastScannedAt: now,
scanWasCancelled: false, scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads, excludedDownloadedCount: skippedDownloads,
); );
await _pruneLibraryCoverCache(persistedItems);
_log.i( _log.i(
'Full scan complete: ${persistedItems.length} tracks found, ' 'Full scan complete: ${persistedItems.length} tracks found, '
@@ -403,11 +421,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} }
if (_scanCancelRequested) { if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(
isScanning: false,
scanIsFinalizing: false,
scanWasCancelled: true,
);
await _showScanCancelledNotification(); await _showScanCancelledNotification();
return; return;
} }
state = state.copyWith(
scanIsFinalizing: true,
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
scanCurrentFile: null,
);
final scannedList = final scannedList =
(result['files'] as List<dynamic>?) ?? (result['files'] as List<dynamic>?) ??
(result['scanned'] as List<dynamic>?) ?? (result['scanned'] as List<dynamic>?) ??
@@ -497,6 +525,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith( state = state.copyWith(
items: items, items: items,
isScanning: false, isScanning: false,
scanIsFinalizing: false,
scanProgress: 100, scanProgress: 100,
lastScannedAt: now, lastScannedAt: now,
scanWasCancelled: false, scanWasCancelled: false,
@@ -516,7 +545,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} }
} 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,
scanIsFinalizing: false,
scanWasCancelled: false,
);
await _showScanFailedNotification(e.toString()); await _showScanFailedNotification(e.toString());
} finally { } finally {
if (didStartSecurityAccess) { if (didStartSecurityAccess) {
@@ -573,16 +606,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
cancelOnError: false, cancelOnError: false,
); );
_progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () { Future<void>.microtask(_requestProgressSnapshot);
if (_hasReceivedProgressStreamEvent) {
return; _progressStreamBootstrapTimer = Timer(
} _progressStreamBootstrapTimeout,
_log.w('Library scan progress stream timeout, fallback to polling'); () {
_progressStreamSub?.cancel(); if (_hasReceivedProgressStreamEvent) {
_progressStreamSub = null; return;
_usingProgressStream = false; }
_startProgressPollingTimer(); _log.w('Library scan progress stream timeout, fallback to polling');
}); _progressStreamSub?.cancel();
_progressStreamSub = null;
_usingProgressStream = false;
_startProgressPollingTimer();
},
);
return; return;
} }
@@ -609,20 +647,41 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}); });
} }
Future<void> _requestProgressSnapshot() async {
if (_isProgressPollingInFlight) return;
_isProgressPollingInFlight = true;
try {
final progress = await PlatformBridge.getLibraryScanProgress();
await _handleLibraryScanProgress(progress);
_progressPollingErrorCount = 0;
} catch (e) {
_progressPollingErrorCount++;
if (_progressPollingErrorCount <= 3) {
_log.w('Initial library scan progress fetch failed: $e');
}
} finally {
_isProgressPollingInFlight = false;
}
}
Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async { Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async {
final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0; final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0;
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
0.0, 0.0,
100.0, 100.0,
); );
final isComplete = progress['is_complete'] == true;
final displayProgress = isComplete
? 99.0
: (normalizedProgress >= 100.0 ? 99.0 : normalizedProgress);
final currentFile = progress['current_file'] as String?; final currentFile = progress['current_file'] as String?;
final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0; final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0;
final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0; final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0;
final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0; final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0;
final isComplete = progress['is_complete'] == true;
final shouldUpdateState = final shouldUpdateState =
state.scanProgress != normalizedProgress || state.scanProgress != displayProgress ||
state.scanIsFinalizing != isComplete ||
state.scanCurrentFile != currentFile || state.scanCurrentFile != currentFile ||
state.scanTotalFiles != totalFiles || state.scanTotalFiles != totalFiles ||
state.scannedFiles != scannedFiles || state.scannedFiles != scannedFiles ||
@@ -630,8 +689,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (shouldUpdateState) { if (shouldUpdateState) {
state = state.copyWith( state = state.copyWith(
scanProgress: normalizedProgress, scanIsFinalizing: isComplete,
scanCurrentFile: currentFile, scanProgress: displayProgress,
scanCurrentFile: isComplete ? null : currentFile,
scanTotalFiles: totalFiles, scanTotalFiles: totalFiles,
scannedFiles: scannedFiles, scannedFiles: scannedFiles,
scanErrorCount: errorCount, scanErrorCount: errorCount,
@@ -704,7 +764,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Cancelling library scan'); _log.i('Cancelling library scan');
_scanCancelRequested = true; _scanCancelRequested = true;
await PlatformBridge.cancelLibraryScan(); await PlatformBridge.cancelLibraryScan();
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(
isScanning: false,
scanIsFinalizing: false,
scanWasCancelled: true,
);
_stopProgressPolling(); _stopProgressPolling();
await _showScanCancelledNotification(); await _showScanCancelledNotification();
} }
@@ -815,6 +879,46 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Library cleared'); _log.i('Library cleared');
} }
Future<void> _pruneLibraryCoverCache(Iterable<LocalLibraryItem> items) async {
try {
final appSupportDir = await getApplicationSupportDirectory();
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
if (!await libraryCoverDir.exists()) {
return;
}
final referencedCoverPaths = items
.map((item) => item.coverPath)
.whereType<String>()
.where((path) => path.isNotEmpty)
.toSet();
var deletedCount = 0;
await for (final entity in libraryCoverDir.list(
recursive: true,
followLinks: false,
)) {
if (entity is! File || referencedCoverPaths.contains(entity.path)) {
continue;
}
try {
await entity.delete();
deletedCount++;
} catch (e) {
_log.w(
'Failed deleting stale library cover cache ${entity.path}: $e',
);
}
}
if (deletedCount > 0) {
_log.i('Pruned $deletedCount stale library cover cache files');
}
} catch (e) {
_log.w('Failed pruning library cover cache: $e');
}
}
Future<void> removeItem(String id) async { Future<void> removeItem(String id) async {
await _db.delete(id); await _db.delete(id);
state = state.copyWith( state = state.copyWith(
+43 -55
View File
@@ -6,12 +6,13 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings'; const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version'; const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 7; const _currentMigrationVersion = 9;
const _spotifyClientSecretKey = 'spotify_client_secret'; const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider'); final _log = AppLogger('SettingsProvider');
@@ -34,14 +35,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
final prefs = await _prefs; final prefs = await _prefs;
final json = prefs.getString(_settingsKey); final json = prefs.getString(_settingsKey);
if (json != null) { if (json != null) {
state = AppSettings.fromJson(jsonDecode(json)); state = AppSettings.fromJson(
Map<String, dynamic>.from(jsonDecode(json) as Map),
);
await _runMigrations(prefs); await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded(); await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeSongLinkRegionIfNeeded(); await _normalizeSongLinkRegionIfNeeded();
} }
await _retireBuiltInSpotifyProvider(); await _cleanupRetiredSpotifySettings();
LogBuffer.loggingEnabled = state.enableLogging; LogBuffer.loggingEnabled = state.enableLogging;
@@ -52,7 +55,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
void _syncLyricsSettingsToBackend() { void _syncLyricsSettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return; if (!PlatformBridge.supportsCoreBackend) return;
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) { PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((
Object e,
) {
_log.w('Failed to sync lyrics providers to backend: $e'); _log.w('Failed to sync lyrics providers to backend: $e');
}); });
@@ -61,7 +66,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
'include_romanization_netease': state.lyricsIncludeRomanizationNetease, 'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord, 'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
'musixmatch_language': state.musixmatchLanguage, 'musixmatch_language': state.musixmatchLanguage,
}).catchError((e) { }).catchError((Object e) {
_log.w('Failed to sync lyrics fetch options to backend: $e'); _log.w('Failed to sync lyrics fetch options to backend: $e');
}); });
} }
@@ -73,7 +78,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
PlatformBridge.setNetworkCompatibilityOptions( PlatformBridge.setNetworkCompatibilityOptions(
allowHttp: compatibilityMode, allowHttp: compatibilityMode,
insecureTls: compatibilityMode, insecureTls: compatibilityMode,
).catchError((e) { ).catchError((Object e) {
_log.w('Failed to sync network compatibility options to backend: $e'); _log.w('Failed to sync network compatibility options to backend: $e');
}); });
} }
@@ -81,13 +86,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
Future<void> _runMigrations(SharedPreferences prefs) async { Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < 1) {
if (!state.useCustomSpotifyCredentials) {
state = state.copyWith(metadataSource: 'deezer');
await _saveSettings();
}
}
if (lastMigration < _currentMigrationVersion) { if (lastMigration < _currentMigrationVersion) {
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') { if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
state = state.copyWith(storageMode: 'saf'); state = state.copyWith(storageMode: 'saf');
@@ -96,26 +94,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
if (!state.isFirstLaunch && !state.hasCompletedTutorial) { if (!state.isFirstLaunch && !state.hasCompletedTutorial) {
state = state.copyWith(hasCompletedTutorial: true); state = state.copyWith(hasCompletedTutorial: true);
} }
// Migration 4: include Spotify Lyrics API in provider order for existing users if (state.lyricsProviders.contains('spotify_api')) {
if (!state.lyricsProviders.contains('spotify_api')) { final updatedProviders = state.lyricsProviders
final updatedProviders = List<String>.from(state.lyricsProviders); .where((provider) => provider != 'spotify_api')
final lrclibIndex = updatedProviders.indexOf('lrclib'); .toList();
if (lrclibIndex >= 0) {
updatedProviders.insert(lrclibIndex + 1, 'spotify_api');
} else {
updatedProviders.add('spotify_api');
}
state = state.copyWith(lyricsProviders: updatedProviders);
}
if (state.metadataSource != 'deezer' ||
state.spotifyClientId.isNotEmpty ||
state.spotifyClientSecret.isNotEmpty ||
state.useCustomSpotifyCredentials) {
state = state.copyWith( state = state.copyWith(
metadataSource: 'deezer', lyricsProviders: updatedProviders.isEmpty
spotifyClientId: '', ? const [
spotifyClientSecret: '', 'lrclib',
useCustomSpotifyCredentials: false, 'musixmatch',
'netease',
'apple_music',
'qqmusic',
]
: updatedProviders,
); );
} }
state = state.copyWith(lastSeenVersion: AppInfo.version); state = state.copyWith(lastSeenVersion: AppInfo.version);
@@ -129,8 +121,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> _saveSettings() async { Future<void> _saveSettings() async {
final settingsToSave = state.copyWith(spotifyClientSecret: ''); _pendingSettingsJson = jsonEncode(state.toJson());
_pendingSettingsJson = jsonEncode(settingsToSave.toJson());
if (_isSavingSettings) { if (_isSavingSettings) {
_saveQueued = true; _saveQueued = true;
@@ -181,28 +172,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings(); await _saveSettings();
} }
Future<void> _retireBuiltInSpotifyProvider() async { Future<void> _cleanupRetiredSpotifySettings() async {
final storedSecret = await _secureStorage.read( final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey, key: _spotifyClientSecretKey,
); );
if (storedSecret != null && storedSecret.isNotEmpty) { if (storedSecret != null && storedSecret.isNotEmpty) {
await _secureStorage.delete(key: _spotifyClientSecretKey); await _secureStorage.delete(key: _spotifyClientSecretKey);
} }
if (state.metadataSource == 'deezer' &&
state.spotifyClientId.isEmpty &&
state.spotifyClientSecret.isEmpty &&
!state.useCustomSpotifyCredentials) {
return;
}
state = state.copyWith(
metadataSource: 'deezer',
spotifyClientId: '',
spotifyClientSecret: '',
useCustomSpotifyCredentials: false,
);
await _saveSettings();
} }
void setDefaultService(String service) { void setDefaultService(String service) {
@@ -220,6 +196,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setSingleFilenameFormat(String format) {
state = state.copyWith(singleFilenameFormat: format);
_saveSettings();
}
void setDownloadDirectory(String directory) { void setDownloadDirectory(String directory) {
state = state.copyWith(downloadDirectory: directory); state = state.copyWith(downloadDirectory: directory);
_saveSettings(); _saveSettings();
@@ -251,11 +232,23 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setEmbedReplayGain(bool enabled) {
state = state.copyWith(embedReplayGain: enabled);
_saveSettings();
}
void setEmbedMetadata(bool enabled) { void setEmbedMetadata(bool enabled) {
state = state.copyWith(embedMetadata: enabled); state = state.copyWith(embedMetadata: enabled);
_saveSettings(); _saveSettings();
} }
void setArtistTagMode(String mode) {
if (mode == artistTagModeJoined || mode == artistTagModeSplitVorbis) {
state = state.copyWith(artistTagMode: mode);
_saveSettings();
}
}
void setLyricsMode(String mode) { void setLyricsMode(String mode) {
if (mode == 'embed' || mode == 'external' || mode == 'both') { if (mode == 'embed' || mode == 'external' || mode == 'both') {
state = state.copyWith(lyricsMode: mode); state = state.copyWith(lyricsMode: mode);
@@ -368,11 +361,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setMetadataSource(String source) {
state = state.copyWith(metadataSource: source);
_saveSettings();
}
void setSearchProvider(String? provider) { void setSearchProvider(String? provider) {
if (provider == null || provider.isEmpty) { if (provider == null || provider.isEmpty) {
state = state.copyWith(clearSearchProvider: true); state = state.copyWith(clearSearchProvider: true);
-8
View File
@@ -146,7 +146,6 @@ class StoreState {
this.registryUrl = '', this.registryUrl = '',
}); });
/// Whether a registry URL has been configured by the user.
bool get hasRegistryUrl => registryUrl.isNotEmpty; bool get hasRegistryUrl => registryUrl.isNotEmpty;
StoreState copyWith({ StoreState copyWith({
@@ -218,7 +217,6 @@ class StoreNotifier extends Notifier<StoreState> {
Future<void> initialize(String cacheDir) async { Future<void> initialize(String cacheDir) async {
if (state.isInitialized) return; if (state.isInitialized) return;
// Load saved registry URL early to avoid UI flash (empty setup screen)
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? ''; final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
@@ -246,8 +244,6 @@ class StoreNotifier extends Notifier<StoreState> {
} }
} }
/// Sets the registry URL, saves it, and refreshes the store.
/// The Go backend handles URL normalisation (GitHub repo raw URL, branch detection).
Future<void> setRegistryUrl(String url) async { Future<void> setRegistryUrl(String url) async {
final trimmed = url.trim(); final trimmed = url.trim();
if (trimmed.isEmpty) { if (trimmed.isEmpty) {
@@ -258,10 +254,8 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(isLoading: true, clearError: true); state = state.copyWith(isLoading: true, clearError: true);
try { try {
// Go backend resolves GitHub URLs (detects default branch) and validates HTTPS.
await PlatformBridge.setStoreRegistryUrl(trimmed); await PlatformBridge.setStoreRegistryUrl(trimmed);
// Read back the resolved URL (may differ from input after normalisation).
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl(); final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -280,13 +274,11 @@ class StoreNotifier extends Notifier<StoreState> {
} }
} }
/// Removes the saved registry URL and fully detaches the repo from backend.
Future<void> removeRegistryUrl() async { Future<void> removeRegistryUrl() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_registryUrlPrefKey); await prefs.remove(_registryUrlPrefKey);
// Reset the URL in Go backend memory AND clear its cache
await PlatformBridge.clearStoreRegistryUrl(); await PlatformBridge.clearStoreRegistryUrl();
state = state.copyWith( state = state.copyWith(
+16 -91
View File
@@ -234,7 +234,7 @@ class TrackNotifier extends Notifier<TrackState> {
} }
if (attempt < 3) { if (attempt < 3) {
await Future.delayed(const Duration(milliseconds: 500)); await Future<void>.delayed(const Duration(milliseconds: 500));
} }
} }
@@ -275,10 +275,12 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
albumId: result['album']?['id'] as String?, albumId:
(result['album'] as Map<String, dynamic>?)?['id'] as String?,
albumName: albumName:
result['name'] as String? ?? result['name'] as String? ??
result['album']?['name'] as String?, (result['album'] as Map<String, dynamic>?)?['name']
as String?,
playlistName: type == 'playlist' playlistName: type == 'playlist'
? result['name'] as String? ? result['name'] as String?
: null, : null,
@@ -536,90 +538,11 @@ class TrackNotifier extends Notifier<TrackState> {
return; return;
} }
final isSpotifyUrl = state = TrackState(
url.contains('open.spotify.com') || isLoading: false,
url.contains('spotify.link') || error: 'url_not_recognized',
url.startsWith('spotify:'); hasSearchText: state.hasSearchText,
if (!isSpotifyUrl) { );
state = TrackState(
isLoading: false,
error: 'url_not_recognized',
hasSearchText: state.hasSearchText,
);
return;
}
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
Map<String, dynamic> metadata;
try {
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
} catch (e) {
rethrow;
}
if (!_isRequestValid(requestId)) return;
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: parsed['id'] as String?,
albumName: albumInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistName,
coverUrl: coverUrl,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
} catch (e) { } catch (e) {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
state = TrackState( state = TrackState(
@@ -825,8 +748,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: true, isLoading: true,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess, isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: selectedSearchFilter: state.selectedSearchFilter,
state.selectedSearchFilter,
); );
try { try {
@@ -921,8 +843,7 @@ class TrackNotifier extends Notifier<TrackState> {
final tracks = List<Track>.from(state.tracks); final tracks = List<Track>.from(state.tracks);
tracks[index] = updatedTrack; tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks); state = state.copyWith(tracks: tracks);
} catch (_) { } catch (_) {}
}
} }
void clear() { void clear() {
@@ -985,9 +906,11 @@ class TrackNotifier extends Notifier<TrackState> {
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?, albumType: data['album_type'] as String?,
totalTracks: data['total_tracks'] as int?, totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
); );
} }
@@ -1018,10 +941,12 @@ class TrackNotifier extends Notifier<TrackState> {
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?, totalTracks: data['total_tracks'] as int?,
source: effectiveSource, source: effectiveSource,
albumType: data['album_type']?.toString(), albumType: data['album_type']?.toString(),
composer: data['composer']?.toString(),
itemType: itemType, itemType: itemType,
); );
} }
+91 -24
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
@@ -138,14 +139,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return (mediaSize.height * 0.55).clamp(360.0, 520.0); return (mediaSize.height * 0.55).clamp(360.0, 520.0);
} }
/// Upgrade cover URL to a higher resolution for full-screen display.
String? _highResCoverUrl(String? url) { String? _highResCoverUrl(String? url) {
if (url == null) return null; if (url == null) return null;
// Spotify CDN: upgrade 300 640 only (no intermediate between 640 and 2000)
if (url.contains('ab67616d00001e02')) { if (url.contains('ab67616d00001e02')) {
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273'); return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
} }
// Deezer CDN: upgrade to 1000x1000
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$'); final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) { if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
return url.replaceAllMapped( return url.replaceAllMapped(
@@ -174,42 +172,107 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Future<void> _fetchTracks() async { Future<void> _fetchTracks() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
Map<String, dynamic> metadata;
if (widget.albumId.startsWith('deezer:')) { if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', ''); final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
metadata = await PlatformBridge.getDeezerMetadata( final metadata = await PlatformBridge.getDeezerMetadata(
'album', 'album',
deezerAlbumId, deezerAlbumId,
); );
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
return;
} else if (widget.albumId.startsWith('qobuz:')) { } else if (widget.albumId.startsWith('qobuz:')) {
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', ''); final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
metadata = await PlatformBridge.getQobuzMetadata('album', qobuzAlbumId); final metadata = await PlatformBridge.getQobuzMetadata(
'album',
qobuzAlbumId,
);
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
return;
} else if (widget.albumId.startsWith('tidal:')) { } else if (widget.albumId.startsWith('tidal:')) {
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', ''); final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
metadata = await PlatformBridge.getTidalMetadata('album', tidalAlbumId); final metadata = await PlatformBridge.getTidalMetadata(
'album',
tidalAlbumId,
);
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
return;
} else { } else {
final url = 'https://open.spotify.com/album/${widget.albumId}'; final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); final result = await PlatformBridge.handleURLWithExtension(url);
} if (result == null || result['tracks'] == null) {
throw StateError('Failed to load album metadata from extension');
}
final trackList = metadata['track_list'] as List<dynamic>; final trackList = result['tracks'] as List<dynamic>;
final tracks = trackList final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>)) .map((t) => _parseTrack(t as Map<String, dynamic>))
.toList(); .toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?; final albumInfo = result['album'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.toString();
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
if (mounted) { if (mounted) {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_isLoading = false; _isLoading = false;
}); });
}
return;
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@@ -236,9 +299,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?, albumType: data['album_type'] as String?,
totalTracks: data['total_tracks'] as int?, totalTracks: data['total_tracks'] as int?,
composer: data['composer']?.toString(),
); );
} }
@@ -328,6 +393,7 @@ 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 cacheWidth = coverCacheWidthForViewport(context);
return FlexibleSpaceBar( return FlexibleSpaceBar(
collapseMode: CollapseMode.pin, collapseMode: CollapseMode.pin,
@@ -339,6 +405,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
imageUrl: imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => placeholder: (_, _) =>
Container(color: colorScheme.surface), Container(color: colorScheme.surface),

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