Compare commits

..

103 Commits

Author SHA1 Message Date
zarzet 86b8709ea1 chore: enable Ukrainian locale on main 2026-04-18 23:02:00 +07:00
zarzet 702b917929 chore: import l10n updates from l10n_dev into main 2026-04-18 22:35:57 +07:00
github-actions[bot] 4b219ad18e chore: update AltStore source to v4.3.1 2026-04-14 14:21:29 +00:00
zarzet 57051bd649 fix: handle .mp4 as alias for .m4a throughout download pipeline 2026-04-14 21:12:14 +07:00
zarzet d6fca6ca55 feat: carry extension download metadata through host pipeline and avoid FLAC-only genre/label pre-embed on non-FLAC files 2026-04-14 21:12:14 +07:00
zarzet 153ec2d9e5 chore: bump version to 4.3.1+126 2026-04-14 21:12:14 +07:00
zarzet be90e85d94 fix: show filter button in all/singles modes when tracks are empty but filters are active 2026-04-14 21:12:14 +07:00
zarzet 4af089f56c feat: improve Tidal metadata (copyright, album artist), remove Qobuz metadata search fallback, fix DATE/YAR tag sync 2026-04-14 21:12:14 +07:00
zarzet 62519d2d1c feat: add preserveNativeOutputExtensions capability for extensions 2026-04-14 21:12:14 +07:00
zarzet 27c0880e87 feat: convert M4A to FLAC when extension doesn't prefer native M4A output
When an extension's preferred output isn't .m4a, downloaded M4A streams
are now automatically converted to FLAC via FFmpeg instead of being
preserved. This applies to both SAF and non-SAF download paths.
2026-04-14 21:12:14 +07:00
zarzet f312b74b30 fix: ensure non-null search provider fallback and update default labels to Tidal
- Add monochrome.tf and samidy.com Tidal API mirrors
- Guarantee resolvedProvider is never null by defaulting to 'tidal'
- Replace stale 'Deezer' default label with 'Tidal' (Deezer moved to extension)
- Show dynamic provider target in auto label for search dropdown
2026-04-14 21:12:14 +07:00
zarzet bd49e307ef fix: reset OutputExt on extension→extension fallback too 2026-04-14 21:12:14 +07:00
zarzet e904a836c1 fix: reset OutputExt on extension→built-in fallback 2026-04-14 21:12:14 +07:00
zarzet 763c9478f1 fix: normalize extension codec for built-in fallback, remove dead Tidal ISRC 2026-04-14 21:12:13 +07:00
zarzet 427bdf74dc chore: reduce Gradle memory, add extension network timeout, fix tr locale 2026-04-14 21:12:13 +07:00
zarzet 373a276c54 fix: respect user provider choice over source extension priority 2026-04-14 21:12:13 +07:00
github-actions[bot] dccadf1f87 chore: update AltStore source to v4.3.1 2026-04-14 13:58:11 +00:00
github-actions[bot] d9933fe038 chore: update AltStore source to v4.3.0 2026-04-13 16:39:57 +00:00
zarzet d47ac0934d chore: bump version to 4.3.0 and fix SAF document file race condition
- Bump app version to 4.3.0 (build 125)
- Extract createOrReuseDocumentFile() to handle SAF auto-rename races
  between findFile() and createFile(), preferring the exact-named sibling
  and discarding duplicate documents
2026-04-13 23:35:03 +07:00
zarzet dbba4d6630 feat: propagate download cancel to extension HTTP requests and fix SAF filename extension mismatch
- Bind cancel context to all extension HTTP calls (fetch, httpGet, httpPost,
  httpRequest, fileDownload, authExchangeCodeWithPKCE) so in-flight requests
  are aborted when user cancels a download
- Make initDownloadCancel idempotent: return existing context if entry already
  exists and preserve pre-cancelled state
- Force SAF output filename to match actual file extension when extension
  returns a different format than requested (e.g. FLAC requested but M4A produced)
- Map ALAC/AAC quality to .m4a instead of falling through to default .flac
2026-04-13 23:35:03 +07:00
zarzet 7405855e01 fix: handle extension oauth callback on ios 2026-04-13 23:35:03 +07:00
zarzet ed020c9303 feat: native M4A ReplayGain tag writing and SAF picker error handling 2026-04-13 23:35:03 +07:00
zarzet 378742e37a refactor: remove author field from extension manifest and UI 2026-04-13 23:35:03 +07:00
zarzet c79bee534e fix: align default search tab layout with primary provider selector using Row+Expanded 2026-04-13 23:35:03 +07:00
zarzet 1d6df75829 fix: preserve existing M4A metadata during embed and enable BuildConfig generation 2026-04-13 23:35:03 +07:00
zarzet b7f51b5f14 feat: expose extension utils, preserve M4A native container, and bump to v4.2.3+124 2026-04-13 23:35:03 +07:00
zarzet 1c8e9df727 feat: add artist search filter and normalize search filter handling 2026-04-13 23:35:03 +07:00
zarzet 01540fe3fc fix: improve ALAC M4A quality parsing 2026-04-13 23:35:03 +07:00
zarzet 071db2f109 refactor: move deezer search flow to extension 2026-04-13 23:35:02 +07:00
zarzet e097d3f605 fix: stabilize shared extension link handling 2026-04-13 23:35:02 +07:00
zarzet 277f783f62 feat: add default search tab preference 2026-04-13 23:35:02 +07:00
zarzet 7637aaf168 fix: fallback extra metadata genre 2026-04-13 23:35:02 +07:00
zarzet c4878470bf chore: thank Ldav Nico and Feuerstern on donate page 2026-04-13 23:35:02 +07:00
zarzet a3725e8c48 feat: add keep android open link 2026-04-13 23:35:02 +07:00
zarzet 917ba842f5 fix: align metadata sanitization and lyrics editing 2026-04-13 23:32:19 +07:00
zarzet dac17ead33 chore: bump app to v4.2.2 2026-04-13 23:32:19 +07:00
zarzet 6845ebe04c refactor: move deezer to extension 2026-04-13 23:32:18 +07:00
zarzet eff709480d fix: preserve flat singles output for extension releases 2026-04-13 23:32:18 +07:00
zarzet 67833424cc fix: align re-enrich matching with autofill metadata 2026-04-13 23:32:18 +07:00
zarzet 5c48e1b476 fix: persist downloaded metadata and refine metadata navigation 2026-04-13 23:32:18 +07:00
zarzet 5e17c9f238 feat: add configurable extension download fallback 2026-04-13 23:32:18 +07:00
zarzet 7d330fb2ec fix: preserve composer metadata across qobuz and history 2026-04-13 23:32:18 +07:00
zarzet cd6a4594fa chore: bump app version to v4.2.1 2026-04-13 23:32:17 +07:00
zarzet bcf727f4ec fix: remove stale audio service manifest entries causing crashes on some devices 2026-04-13 23:32:17 +07:00
zarzet 4c4553913f fix: harden gomobile extension bindings and m4a cover retention 2026-04-13 23:32:17 +07:00
zarzet f0013fac16 fix: preserve local convert format and library entries 2026-04-13 23:32:17 +07:00
zarzet ce4be0ba97 feat: enrich composer and track totals metadata 2026-04-13 23:32:17 +07:00
zarzet 4bac38ef2a fix: preserve embedded metadata details 2026-04-13 23:32:17 +07:00
zarzet 4b213f47d9 ci: pin iOS release builds to macOS 15 and Xcode 26.1.1 2026-04-13 23:32:16 +07:00
zarzet a1010f72f2 fix: patch device_info_plus iOS build for older Xcode SDKs 2026-04-13 23:32:16 +07:00
zarzet 21077a26d0 feat: add additional search/metadata API with separate rate limiting 2026-04-13 23:32:16 +07:00
zarzet b50eec5a47 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-13 23:32:16 +07:00
zarzet 38a8b715f8 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-13 23:32:16 +07:00
zarzet 2b47537bb5 fix: route Qobuz API calls through authenticated gateway to resolve 401 errors 2026-04-13 23:32:16 +07:00
zarzet a5cf241846 refactor: consolidate FLAC/MP3/Opus metadata embedding into unified _embedMetadataToFile 2026-04-13 23:32:16 +07:00
zarzet 53a4773480 feat: add skipLyrics manifest field for extensions to opt out of lyrics fetching 2026-04-13 23:32:16 +07:00
zarzet 89603af1f1 chore: remove redundant comments and update donor list 2026-04-13 23:32:15 +07:00
zarzet 2143084d3c 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-13 23:32:15 +07:00
zarzet 0e265193b8 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-13 23:32:15 +07:00
zarzet c7e9749ce4 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-13 23:32:15 +07:00
zarzet e21cffff0b 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-13 23:32:15 +07:00
zarzet d9e20040be 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-13 23:32:15 +07:00
zarzet 6689173525 chore: bump version to 4.2.0 (build 121) 2026-04-13 23:32:14 +07:00
zarzet f37e4704a6 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-13 23:32:14 +07:00
zarzet 65dbd5c8e4 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-13 23:32:14 +07:00
zarzet d034144e9c 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-13 23:32:14 +07:00
zarzet 7c4309955e 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-04-13 23:32:13 +07:00
zarzet 63e90d13d4 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-04-13 23:32:13 +07:00
zarzet bfb0cad603 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-04-13 23:32:13 +07:00
zarzet cc10a917dc 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-04-13 23:32:13 +07:00
zarzet 5e833c1f75 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-04-13 23:32:13 +07:00
zarzet 8c576ac7e4 chore: bump version to 4.1.3 (build 120) 2026-04-13 23:32:13 +07:00
zarzet 92160537c0 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-04-13 23:32:13 +07:00
zarzet 120ecaa0e5 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-04-13 23:32:12 +07:00
zarzet fd3a34303e 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-04-13 23:32:12 +07:00
zarzet d89b70e155 fix: use Tidal quality options as fallback instead of DEFAULT for extensions 2026-04-13 23:32:12 +07:00
zarzet e3b63c1d27 fix: normalize DEFAULT quality to prevent Tidal/Qobuz API failures 2026-04-13 23:32:12 +07:00
zarzet 96301c0dbf 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-04-13 23:32:12 +07:00
zarzet a2458c1292 refactor: extract and improve ReEnrich track selection with scoring-based matching 2026-04-13 23:32:11 +07:00
zarzet 1737e12dd2 fix: add attached_pic disposition to ALAC cover art embedding 2026-04-13 23:32:11 +07:00
zarzet b770d7d9ca 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-04-13 23:32:11 +07:00
zarzet b712b9f509 refactor: route spotify URLs through extensions 2026-04-13 23:32:11 +07:00
zarzet 51496cd34e chore: bump version to 4.1.2+119 2026-04-13 23:32:11 +07:00
zarzet 2b2c2bc90a feat: improve track matching 2026-04-13 23:32:11 +07:00
zarzet e2a489ec92 feat: add haptic feedback when swiping library tabs 2026-04-13 23:32:11 +07:00
zarzet 4f46dd947d 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-04-13 23:32:11 +07:00
zarzet fbb8d30db0 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-04-13 23:32:10 +07:00
Zarz Eleutherius c0637006af Merge pull request #313 from AlexRabbit/main
Extension OAuth + store: flatten action JSON, open auth URLs, spotifl…
2026-04-13 23:30:56 +07:00
Alex 3fc371b8c4 Extension OAuth + store: flatten action JSON, open auth URLs, spotiflac:// callback
Third-party extensions (e.g. Spotify PKCE addons) need three things the current app does not fully provide:

Extension button results – The Go runtime returned { success, result: { message, open_auth_url, … } } while Flutter read message / open_auth_url only on the outer map, so OAuth buttons appeared to do nothing. InvokeAction now merges the extension’s return object onto the top-level JSON (arrays/non-objects still use result).

Flutter – extension_detail_page: unwrap nested result for compatibility, merge setting_updates into saved extension settings (for copyable OAuth URLs), and launchUrl when open_auth_url is set.

Mobile OAuth return – spotiflac://callback?code=…&state=<extension_id> was not handled on Android (manifest + MainActivity) or iOS (AppDelegate open URL + cold-start launchOptions). This wires SetExtensionAuthCodeByID + invokeExtensionAction(..., "completeSpotifyLogin") so PKCE extensions can finish login after the browser redirect.

Extension store HTTP – Add Cache-Control: no-cache on registry and extension package downloads to reduce stale CDN/proxy responses.

Testing: Install a metadata extension that uses PKCE; tap Connect; confirm browser opens, return via spotiflac://callback, and tokens complete without pasting the code manually.

extension InvokeAction JSON was nested under result while the Flutter settings UI only read the top level, so OAuth-related buttons never showed messages or opened the browser. This PR flattens that payload, merges optional setting_updates, launches open_auth_url, adds spotiflac://callback handling on Android and iOS, and sends no-cache on store HTTP fetches. Needed for extensions like SpoitiLists (Spotify Web API + PKCE).
2026-04-12 02:40:31 -06:00
Zarz Eleutherius ee5b3824e9 Merge pull request #297 from mikropsoft/patch-1
Update app_tr.arb
2026-04-09 16:29:33 +07:00
github-actions[bot] be6a856773 chore: update AltStore source to v4.2.2 2026-04-06 07:38:35 +00:00
github-actions[bot] e41c299d49 chore: update AltStore source to v4.2.1 2026-04-04 15:02:40 +00:00
Zarz Eleutherius 981786b4a2 Merge pull request #298 from Amonoman/main
Center images in layout
2026-04-04 19:06:24 +07:00
Amonoman eeb6f11808 Center the images 2026-04-04 13:27:33 +02:00
𝗛𝗼𝗹𝗶 8e361e14b4 Update app_tr.arb 2026-04-04 12:05:33 +03:00
github-actions[bot] d58d46eb1f chore: update AltStore source to v4.2.0 2026-04-04 09:04:47 +00:00
Zarz Eleutherius 562a17f7ae Merge pull request #295 from Amonoman/main
Update logo
2026-04-04 01:24:34 +07:00
Amonoman b035e66540 Update logo 2026-04-03 18:28:16 +02:00
github-actions[bot] 38792a753e chore: update AltStore source to v4.1.3 2026-03-30 11:43:03 +00:00
github-actions[bot] d5b34b4f15 chore: update AltStore source to v4.1.2 2026-03-29 11:31:37 +00:00
github-actions[bot] 2a45c8dcdb chore: update AltStore source to v4.1.1 2026-03-27 15:47:00 +00:00
zarzet e7a2166a4f Merge branch 'dev' 2026-03-27 22:34:15 +07:00
github-actions[bot] f54597e655 chore: update AltStore source to v4.1.0 2026-03-26 10:47:40 +00:00
137 changed files with 37980 additions and 3224 deletions
+6 -1
View File
@@ -164,13 +164,18 @@ jobs:
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
build-ios:
runs-on: macos-latest
runs-on: macos-15
needs: get-version # Only depends on version, NOT android build!
steps:
- name: Checkout repository
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
uses: actions/setup-go@v6
with:
+1 -1
View File
@@ -168,7 +168,7 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
|---|---|---|---|---|
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
| [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) | [Monochrome](https://monochrome.tf) |
---
+19
View File
@@ -20,6 +20,10 @@ android {
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
buildFeatures {
buildConfig = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
@@ -57,6 +61,18 @@ android {
}
buildTypes {
getByName("debug") {
ndk {
debugSymbolLevel = "FULL"
}
}
getByName("profile") {
ndk {
debugSymbolLevel = "FULL"
}
}
release {
// For local builds: use release signing if key.properties exists
// For CI builds: APK is signed by GitHub Action after build
@@ -71,6 +87,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
ndk {
debugSymbolLevel = "FULL"
}
}
}
+14 -18
View File
@@ -86,6 +86,20 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="music.youtube.com" />
</intent-filter>
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="spotify-callback" />
</intent-filter>
</activity>
<!-- Download Service -->
@@ -94,24 +108,6 @@
android:exported="false"
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 -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
@@ -307,8 +308,40 @@ class MainActivity: FlutterFragmentActivity() {
}
}
private fun forceFilenameExt(name: String, outputExt: String): String {
val normalizedExt = normalizeExt(outputExt)
if (normalizedExt.isBlank()) return sanitizeFilename(name)
val safeName = sanitizeFilename(name)
val lower = safeName.lowercase(Locale.ROOT)
val knownExts = listOf(".flac", ".m4a", ".mp3", ".opus", ".lrc")
for (knownExt in knownExts) {
if (lower.endsWith(knownExt)) {
return safeName.dropLast(knownExt.length) + normalizedExt
}
}
return safeName + normalizedExt
}
private fun sanitizeFilename(name: String): String {
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
var sanitized = name
.replace("/", " ")
.replace(Regex("[\\\\:*?\"<>|]"), " ")
.filter { ch ->
val code = ch.code
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
code == 0x7F ||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
}
.trim()
.trim('.', ' ')
sanitized = sanitized
.replace(Regex("\\s+"), " ")
.replace(Regex("_+"), "_")
.trim('_', ' ')
return if (sanitized.isBlank()) "Unknown" else sanitized
}
private fun sanitizeRelativeDir(relativeDir: String): String {
@@ -368,6 +401,43 @@ class MainActivity: FlutterFragmentActivity() {
return current
}
private fun createOrReuseDocumentFile(
parent: DocumentFile,
mimeType: String,
fileName: String
): DocumentFile? {
val safeFileName = sanitizeFilename(fileName)
if (safeFileName.isBlank()) return null
synchronized(safDirLock) {
val existing = parent.findFile(safeFileName)
if (existing != null && existing.isFile) {
return existing
}
val created = parent.createFile(mimeType, safeFileName) ?: return null
val createdName = created.name ?: safeFileName
if (createdName == safeFileName) {
return created
}
// SAF can auto-rename to "name (1)" when another writer wins the race
// between findFile() and createFile(). Prefer the exact sibling if it
// appeared, and discard the duplicate document we just created.
val winner = parent.findFile(safeFileName)
if (winner != null && winner.isFile) {
if (winner.uri != created.uri) {
try {
created.delete()
} catch (_: Exception) {}
}
return winner
}
return created
}
}
private fun resetSafScanProgress() {
synchronized(safScanLock) {
safScanProgress = SafScanProgress()
@@ -599,12 +669,12 @@ class MainActivity: FlutterFragmentActivity() {
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "")
if (provided.isNotBlank()) return sanitizeFilename(provided)
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
val trackName = req.optString("track_name", "track")
val artistName = req.optString("artist_name", "")
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
return sanitizeFilename(baseName) + outputExt
return forceFilenameExt(baseName, outputExt)
}
private fun errorJson(message: String): String {
@@ -918,8 +988,7 @@ class MainActivity: FlutterFragmentActivity() {
val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory")
val existingFile = targetDir.findFile(fileName)
val document = existingFile ?: targetDir.createFile(mimeType, fileName)
var document = createOrReuseDocumentFile(targetDir, mimeType, fileName)
?: return errorJson("Failed to create SAF file")
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
@@ -944,16 +1013,34 @@ class MainActivity: FlutterFragmentActivity() {
) {
try {
val srcFile = java.io.File(goFilePath)
if (srcFile.exists() && srcFile.length() > 0) {
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
}
}
srcFile.delete()
if (!srcFile.exists() || srcFile.length() <= 0) {
throw IllegalStateException("extension output missing or empty: $goFilePath")
}
val actualExt = normalizeExt(srcFile.extension)
if (actualExt.isNotBlank() && actualExt != outputExt) {
val actualFileName = buildSafFileName(req, actualExt)
val actualMimeType = mimeTypeForExt(actualExt)
val replacement = createOrReuseDocumentFile(
targetDir,
actualMimeType,
actualFileName,
)
?: throw IllegalStateException("failed to create SAF output with actual extension")
if (replacement.uri != document.uri) {
document.delete()
document = replacement
}
}
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: throw IllegalStateException("failed to open SAF output stream")
srcFile.delete()
} catch (e: Exception) {
document.delete()
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}")
return errorJson("Failed to copy extension output to SAF: ${e.message}")
}
}
respObj.put("file_path", document.uri.toString())
@@ -1931,9 +2018,54 @@ class MainActivity: FlutterFragmentActivity() {
// We handle these URLs ourselves via receive_sharing_intent + ShareIntentService.
override fun shouldHandleDeeplinking(): Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleExtensionOAuthIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleExtensionOAuthIntent(intent)
}
/**
* Deliver Spotify (or other) OAuth authorization code to the extension runtime
* and run its token exchange (e.g. completeSpotifyLogin). State must be the extension id.
*/
private fun handleExtensionOAuthIntent(intent: Intent?) {
val uri = intent?.data ?: return
if (!uri.scheme.equals("spotiflac", ignoreCase = true)) {
return
}
val host = (uri.host ?: "").lowercase(Locale.US)
val path = (uri.path ?: "").lowercase(Locale.US)
val isCallback =
host == "callback" ||
host == "spotify-callback" ||
path.contains("callback")
if (!isCallback) {
return
}
val code = uri.getQueryParameter("code")?.trim().orEmpty()
if (code.isEmpty()) {
return
}
val extId = uri.getQueryParameter("state")?.trim().orEmpty()
if (extId.isEmpty()) {
android.util.Log.w("SpotiFLAC", "Extension OAuth redirect missing state (extension id)")
return
}
intent.data = null
scope.launch(Dispatchers.IO) {
try {
Gobackend.setExtensionAuthCodeByID(extId, code)
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
}
}
}
override fun onDestroy() {
@@ -1949,6 +2081,7 @@ class MainActivity: FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Gobackend.setAppVersion(BuildConfig.VERSION_NAME)
// Always-enabled back callback to ensure back presses reach Flutter.
// Nested tab navigators can incorrectly set frameworkHandlesBack(false),
@@ -2133,7 +2266,6 @@ class MainActivity: FlutterFragmentActivity() {
result.error("saf_pending", "SAF picker already active", null)
return@launch
}
pendingSafTreeResult = result
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
@@ -2141,7 +2273,24 @@ class MainActivity: FlutterFragmentActivity() {
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
)
safTreeLauncher.launch(intent)
val resolver = intent.resolveActivity(packageManager)
if (resolver == null) {
result.error("saf_unavailable", "No folder picker available on this device", null)
return@launch
}
pendingSafTreeResult = result
try {
android.util.Log.i("SpotiFLAC", "Launching SAF picker via $resolver")
safTreeLauncher.launch(intent)
} catch (e: Exception) {
pendingSafTreeResult = null
android.util.Log.e("SpotiFLAC", "Failed to launch SAF picker: ${e.message}", e)
result.error(
"saf_launch_failed",
e.message ?: "Failed to launch folder picker",
null
)
}
}
"safExists" -> {
val uriStr = call.argument<String>("uri") ?: ""
@@ -2216,7 +2365,8 @@ class MainActivity: FlutterFragmentActivity() {
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
val existing = dir.findFile(fileName)
val createdNew = existing == null
val doc = existing ?: dir.createFile(mimeType, fileName) ?: return@withContext null
val doc = createOrReuseDocumentFile(dir, mimeType, fileName)
?: return@withContext null
if (!writeUriFromPath(doc.uri, srcPath)) {
if (createdNew) {
doc.delete()
@@ -2714,16 +2864,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"searchDeezerAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"searchTidalAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -2965,6 +3105,13 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"setDownloadFallbackExtensionIds" -> {
val extensionIdsJson = call.argument<String>("extension_ids") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setExtensionFallbackProviderIDsJSON(extensionIdsJson)
}
result.success(null)
}
"setMetadataProviderPriority" -> {
val priorityJson = call.argument<String>("priority") ?: "[]"
withContext(Dispatchers.IO) {
+1 -1
View File
@@ -1,2 +1,2 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+4 -4
View File
@@ -7,12 +7,12 @@
"name": "SpotiFLAC",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "3.9.0",
"versionDate": "2026-03-25",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
"version": "4.3.1",
"versionDate": "2026-04-14",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.3.1/SpotiFLAC-v4.3.1-ios-unsigned.ipa",
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 34477323
"size": 34773644
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 71 KiB

+12 -10
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"io"
"os"
"strconv"
"strings"
)
@@ -367,12 +366,9 @@ func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
case "DATE":
metadata.Date = value
case "TRACK", "TRACKNUMBER":
// APE track format can be "3" or "3/12"
trackNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
metadata.TrackNumber = trackNum
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "DISC", "DISCNUMBER":
discNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
metadata.DiscNumber = discNum
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "ISRC":
metadata.ISRC = value
case "LYRICS", "UNSYNCEDLYRICS":
@@ -425,10 +421,10 @@ func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
addItem("Year", metadata.Year)
}
if metadata.TrackNumber > 0 {
addItem("Track", strconv.Itoa(metadata.TrackNumber))
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
}
if metadata.DiscNumber > 0 {
addItem("Disc", strconv.Itoa(metadata.DiscNumber))
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
}
addItem("ISRC", metadata.ISRC)
addItem("Lyrics", metadata.Lyrics)
@@ -453,7 +449,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
"artist": "ARTIST",
"album": "ALBUM",
"album_artist": "ALBUM ARTIST",
"date": "YEAR",
"date": "DATE",
"genre": "GENRE",
"track_number": "TRACK",
"disc_number": "DISC",
@@ -475,7 +471,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
}
}
// Some fields have reader aliases that must also be cleared when the
// canonical key is updated (e.g. "Year" writer ↔ DATE/YEAR reader,
// 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 {
@@ -484,9 +480,15 @@ func apeKeysFromFields(fields map[string]string) map[string]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{}{}
}
+27 -11
View File
@@ -21,7 +21,9 @@ type AudioMetadata struct {
Year string
Date string
TrackNumber int
TotalTracks int
DiscNumber int
TotalDiscs int
ISRC string
Lyrics string
Label string
@@ -173,9 +175,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
case "TCO":
metadata.Genre = cleanGenre(value)
case "TRK":
metadata.TrackNumber = parseTrackNumber(value)
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "TPA":
metadata.DiscNumber = parseTrackNumber(value)
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "TCM":
metadata.Composer = value
case "TPB":
@@ -292,9 +294,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
case "TCON":
metadata.Genre = cleanGenre(value)
case "TRCK":
metadata.TrackNumber = parseTrackNumber(value)
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "TPOS":
metadata.DiscNumber = parseTrackNumber(value)
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "TSRC":
metadata.ISRC = value
case "TCOM":
@@ -580,14 +582,28 @@ func cleanGenre(genre string) string {
}
func parseTrackNumber(s string) int {
s = strings.TrimSpace(s)
if idx := strings.Index(s, "/"); idx > 0 {
s = s[:idx]
}
num, _ := strconv.Atoi(s)
num, _ := parseIndexPair(s)
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 {
if len(data) == 0 {
return data
@@ -1037,9 +1053,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
case "GENRE":
metadata.Genre = value
case "TRACKNUMBER", "TRACK":
metadata.TrackNumber = parseTrackNumber(value)
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
case "DISCNUMBER", "DISC":
metadata.DiscNumber = parseTrackNumber(value)
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
case "ISRC":
metadata.ISRC = value
case "COMPOSER":
+14
View File
@@ -10,6 +10,7 @@ import (
var ErrDownloadCancelled = errors.New("download cancelled")
type cancelEntry struct {
ctx context.Context
cancel context.CancelFunc
canceled bool
}
@@ -27,8 +28,21 @@ func initDownloadCancel(itemID string) context.Context {
cancelMu.Lock()
defer cancelMu.Unlock()
if entry, ok := cancelMap[itemID]; ok {
if entry.ctx == nil {
ctx, cancel := context.WithCancel(context.Background())
entry.ctx = ctx
entry.cancel = cancel
if entry.canceled && entry.cancel != nil {
entry.cancel()
}
}
return entry.ctx
}
ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{
ctx: ctx,
cancel: cancel,
canceled: false,
}
+8
View File
@@ -513,6 +513,11 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
album = "Unknown Album"
}
composer := track.Composer
if composer == "" {
composer = sheet.Composer
}
var duration int
if i+1 < len(sheet.Tracks) {
nextStart := sheet.Tracks[i+1].StartTime
@@ -539,12 +544,15 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
ScannedAt: scanTime,
ISRC: track.ISRC,
TrackNumber: track.Number,
TotalTracks: len(sheet.Tracks),
DiscNumber: 1,
TotalDiscs: 1,
Duration: duration,
ReleaseDate: sheet.Date,
BitDepth: bitDepth,
SampleRate: sampleRate,
Genre: sheet.Genre,
Composer: composer,
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
}
+7
View File
@@ -630,6 +630,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
}
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
totalDiscs := 0
for _, track := range allTracks {
if track.DiskNumber > totalDiscs {
totalDiscs = track.DiskNumber
}
}
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
albumType := album.RecordType
@@ -658,6 +664,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
TrackNumber: trackNum,
TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber,
TotalDiscs: totalDiscs,
ExternalURL: track.Link,
ISRC: isrc,
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
-444
View File
@@ -1,444 +0,0 @@
package gobackend
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const deezerMusicDLURL = "https://api.zarz.moe/v1/dzr"
type DeezerDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
}
func isLikelySpotifyTrackID(value string) bool {
if len(value) != 22 {
return false
}
for _, r := range value {
switch {
case r >= 'A' && r <= 'Z':
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
default:
return false
}
}
return true
}
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
deezerID := strings.TrimSpace(req.DeezerID)
if deezerID == "" {
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
deezerID = strings.TrimSpace(prefixed)
}
}
if deezerID != "" {
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
// Don't reject direct IDs from request payload — they're presumably correct.
}
return trackURL, nil
}
spotifyID := strings.TrimSpace(req.SpotifyID)
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
songlink := NewSongLinkClient()
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
if err == nil && availability.Deezer && availability.DeezerURL != "" {
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
// Fall through to ISRC search instead of using wrong track.
} else {
return availability.DeezerURL, nil
}
} else {
return availability.DeezerURL, nil
}
}
}
isrc := strings.TrimSpace(req.ISRC)
if isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
if err == nil && track != nil {
resolvedID := songLinkExtractDeezerTrackID(track)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
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.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
}
}
}
return "", fmt.Errorf("could not resolve Deezer track URL")
}
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
if err != nil {
return nil // Can't verify — don't block the download.
}
resolved := resolvedTrackInfo{
Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists,
ISRC: trackResp.Track.ISRC,
Duration: trackResp.Track.DurationMS / 1000,
SkipNameVerification: skipNameVerification,
}
if !trackMatchesRequest(req, resolved, "Deezer") {
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
}
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
return nil
}
type deezerMusicDLRequest struct {
Platform string `json:"platform"`
URL string `json:"url"`
}
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
payload := deezerMusicDLRequest{
Platform: "deezer",
URL: deezerTrackURL,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("MusicDL request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
}
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
return "", fmt.Errorf("MusicDL error: %s", errMsg)
}
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
}
if data, ok := raw["data"].(map[string]any); ok {
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
}
}
return "", fmt.Errorf("no download URL found in MusicDL response")
}
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
if err != nil {
return err
}
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
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, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := GetDownloadClient().Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
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 MusicDL: %.2f MB\n", float64(written)/(1024*1024))
return nil
}
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
deezerClient := GetDeezerClient()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
var outputPath string
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
}
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
coverURL := req.CoverURL
embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata {
coverURL = ""
embedLyrics = false
}
parallelResult = FetchCoverAndLyricsParallel(
coverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
embedLyrics,
int64(req.DurationMS),
)
}()
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
if deezerURLErr != nil {
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed: could not resolve Deezer URL: %w",
deezerURLErr,
)
}
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
downloadErr := deezerClient.DownloadFromMusicDL(
deezerTrackURL,
outputPath,
req.OutputFD,
req.ItemID,
)
if downloadErr != nil {
if errors.Is(downloadErr, ErrDownloadCancelled) {
return DeezerDownloadResult{}, ErrDownloadCancelled
}
return DeezerDownloadResult{}, fmt.Errorf(
"deezer download failed via MusicDL: %w",
downloadErr,
)
}
<-parallelDone
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
}
if isSafOutput || !req.EmbedMetadata {
if !req.EmbedMetadata {
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
} else {
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
}
} else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
}
}
}
}
if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
}
bitDepth, sampleRate := 0, 0
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
}
lyricsLRC := ""
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
return DeezerDownloadResult{
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
}, nil
}
+470 -172
View File
File diff suppressed because it is too large Load Diff
+213 -7
View File
@@ -1,6 +1,24 @@
package gobackend
import "testing"
import (
"context"
"testing"
)
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
}
if got := GetExtensionFallbackProviderIDs(); got != nil {
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
}
}
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
req := DownloadRequest{
@@ -114,6 +132,124 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
}
}
func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) {
req := DownloadRequest{
TrackName: "Track",
ArtistName: "Artist",
}
result := DownloadResult{
Title: "Track",
Artist: "Artist",
DecryptionKey: "00112233",
}
resp := buildDownloadSuccessResponse(
req,
result,
"amazon",
"ok",
"/tmp/test.m4a",
false,
)
if resp.Decryption == nil {
t.Fatal("expected decryption descriptor to be present")
}
if resp.Decryption.Strategy != genericFFmpegMOVDecryptionStrategy {
t.Fatalf("strategy = %q", resp.Decryption.Strategy)
}
if resp.Decryption.Key != result.DecryptionKey {
t.Fatalf("key = %q, want %q", resp.Decryption.Key, result.DecryptionKey)
}
}
func TestFormatMusicBrainzGenrePrefersHighestCountTag(t *testing.T) {
got := formatMusicBrainzGenre([]musicBrainzTag{
{Name: "art pop", Count: 3},
{Name: "pop", Count: 8},
{Name: "dance pop", Count: 5},
})
if got != "Pop" {
t.Fatalf("genre = %q, want %q", got, "Pop")
}
}
func TestEnrichExtraMetadataByISRCFallsBackToMusicBrainzGenre(t *testing.T) {
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
defer func() {
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
}()
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return nil, nil
}
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
if isrc != "TEST123" {
t.Fatalf("unexpected isrc: %q", isrc)
}
return "Alternative Rock", nil
}
genre := ""
label := ""
copyright := ""
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST123", &genre, &label, &copyright)
if genre != "Alternative Rock" {
t.Fatalf("genre = %q, want fallback genre", genre)
}
if label != "" {
t.Fatalf("label = %q, want empty", label)
}
if copyright != "" {
t.Fatalf("copyright = %q, want empty", copyright)
}
}
func TestEnrichExtraMetadataByISRCPrefersDeezerGenre(t *testing.T) {
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
defer func() {
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
}()
musicBrainzCalled := false
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return &AlbumExtendedMetadata{
Genre: "Synthpop",
Label: "EMI",
Copyright: "(C) Test",
}, nil
}
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
musicBrainzCalled = true
return "Rock", nil
}
genre := ""
label := ""
copyright := ""
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST456", &genre, &label, &copyright)
if genre != "Synthpop" {
t.Fatalf("genre = %q, want Deezer genre", genre)
}
if label != "EMI" {
t.Fatalf("label = %q, want Deezer label", label)
}
if copyright != "(C) Test" {
t.Fatalf("copyright = %q, want Deezer copyright", copyright)
}
if musicBrainzCalled {
t.Fatal("expected MusicBrainz not to be called when Deezer already provides genre")
}
}
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
req := reEnrichRequest{
SpotifyID: "spotify-track-id",
@@ -195,13 +331,11 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
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 metadata["TITLE"] != "Song" {
t.Fatalf("title = %q", metadata["TITLE"])
}
if _, exists := metadata["ARTIST"]; exists {
t.Fatalf("ARTIST should not be in metadata: %#v", metadata)
if metadata["ARTIST"] != "Artist" {
t.Fatalf("artist = %q", metadata["ARTIST"])
}
if metadata["ALBUM"] != "Album" {
t.Fatalf("album = %q", metadata["ALBUM"])
@@ -224,3 +358,75 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
}
}
}
func TestBuildReEnrichSearchQuerySkipsPlaceholderArtist(t *testing.T) {
req := reEnrichRequest{
TrackName: "Sign of the Times",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
}
query := buildReEnrichSearchQuery(req)
if query != "Sign of the Times" {
t.Fatalf("query = %q", query)
}
req = reEnrichRequest{
TrackName: "Unknown Title",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
}
query = buildReEnrichSearchQuery(req)
if query != "Harry Styles" {
t.Fatalf("fallback album query = %q", query)
}
}
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
req := reEnrichRequest{}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
Name: "Resolved Song",
Artists: "Resolved Artist",
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.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
}
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"])
}
}
+54 -42
View File
@@ -43,12 +43,12 @@ func compareVersions(v1, v2 string) int {
return 0
}
type LoadedExtension struct {
type loadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *ExtensionRuntime
runtime *extensionRuntime
initialized bool
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
@@ -73,7 +73,7 @@ func getExtensionInitSettings(extensionID string) map[string]interface{} {
return filtered
}
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
if ext.VM == nil || ext.runtime == nil {
if err := initializeVMLocked(ext); err != nil {
ext.Error = err.Error()
@@ -100,14 +100,14 @@ func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) er
return nil
}
func (ext *LoadedExtension) ensureRuntimeReady() error {
func (ext *loadedExtension) ensureRuntimeReady() error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return ensureRuntimeReadyLocked(ext, true)
}
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
ext.VMMu.Lock()
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
ext.VMMu.Unlock()
@@ -116,28 +116,28 @@ func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
return ext.VM, nil
}
type ExtensionManager struct {
type extensionManager struct {
mu sync.RWMutex
extensions map[string]*LoadedExtension
extensions map[string]*loadedExtension
extensionsDir string
dataDir string
}
var (
globalExtManager *ExtensionManager
globalExtManager *extensionManager
globalExtManagerOnce sync.Once
)
func GetExtensionManager() *ExtensionManager {
func getExtensionManager() *extensionManager {
globalExtManagerOnce.Do(func() {
globalExtManager = &ExtensionManager{
extensions: make(map[string]*LoadedExtension),
globalExtManager = &extensionManager{
extensions: make(map[string]*loadedExtension),
}
})
return globalExtManager
}
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -154,7 +154,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
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") {
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)
}
ext := &LoadedExtension{
ext := &loadedExtension{
ID: manifest.Name,
Manifest: manifest,
Enabled: false, // New extensions start disabled
@@ -292,7 +292,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return ext, nil
}
func initializeVMLocked(ext *LoadedExtension) error {
func initializeVMLocked(ext *loadedExtension) error {
ext.VM = nil
ext.runtime = nil
ext.initialized = false
@@ -305,7 +305,7 @@ func initializeVMLocked(ext *LoadedExtension) error {
return fmt.Errorf("failed to read index.js: %w", err)
}
runtime := NewExtensionRuntime(ext)
runtime := newExtensionRuntime(ext)
ext.runtime = runtime
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
@@ -342,14 +342,14 @@ func initializeVMLocked(ext *LoadedExtension) error {
return nil
}
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return initializeVMLocked(ext)
}
func initializeExtensionWithSettingsLocked(
ext *LoadedExtension,
ext *loadedExtension,
settings map[string]interface{},
) error {
if ext.VM == nil {
@@ -405,7 +405,7 @@ func initializeExtensionWithSettingsLocked(
return nil
}
func runCleanupLocked(ext *LoadedExtension) error {
func runCleanupLocked(ext *loadedExtension) error {
if ext.VM != nil {
script := `
(function() {
@@ -446,7 +446,7 @@ func runCleanupLocked(ext *LoadedExtension) error {
return nil
}
func teardownVMLocked(ext *LoadedExtension) {
func teardownVMLocked(ext *loadedExtension) {
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
}
@@ -461,7 +461,7 @@ func teardownVMLocked(ext *LoadedExtension) {
ext.initialized = false
}
func validateExtensionLoad(ext *LoadedExtension) error {
func validateExtensionLoad(ext *loadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
@@ -472,7 +472,7 @@ func validateExtensionLoad(ext *LoadedExtension) error {
return nil
}
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
func (m *extensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -491,7 +491,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return nil
}
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, error) {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -502,18 +502,18 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
return ext, nil
}
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
func (m *extensionManager) GetAllExtensions() []*loadedExtension {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]*LoadedExtension, 0, len(m.extensions))
result := make([]*loadedExtension, 0, len(m.extensions))
for _, ext := range m.extensions {
result = append(result, ext)
}
return result
}
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -547,7 +547,7 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return nil
}
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
var loaded []string
var errors []error
@@ -585,7 +585,7 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
return loaded, errors
}
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedExtension, error) {
m.mu.Lock()
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)
}
ext := &LoadedExtension{
ext := &loadedExtension{
ID: manifest.Name,
Manifest: manifest,
Enabled: false, // Will be restored from settings store
@@ -643,7 +643,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return ext, nil
}
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
func (m *extensionManager) RemoveExtension(extensionID string) error {
ext, err := m.GetExtension(extensionID)
if err != nil {
return err
@@ -663,7 +663,7 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
}
// 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") {
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,
Manifest: newManifest,
Enabled: wasEnabled, // Preserve enabled state from before upgrade
@@ -812,7 +812,7 @@ type ExtensionUpgradeInfo struct {
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") {
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
}
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
info, err := m.checkExtensionUpgradeInternal(filePath)
if err != nil {
return "", err
@@ -885,7 +885,7 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
return string(jsonBytes), nil
}
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions()
type ExtensionInfo struct {
@@ -893,7 +893,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"`
@@ -951,7 +950,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
Name: ext.Manifest.Name,
DisplayName: ext.Manifest.DisplayName,
Version: ext.Manifest.Version,
Author: ext.Manifest.Author,
Description: ext.Manifest.Description,
Homepage: ext.Manifest.Homepage,
IconPath: iconPath,
@@ -982,7 +980,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
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()
defer m.mu.Unlock()
@@ -1000,7 +998,7 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return initializeExtensionWithSettingsLocked(ext, settings)
}
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
func (m *extensionManager) CleanupExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -1022,7 +1020,7 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
return nil
}
func (m *ExtensionManager) UnloadAllExtensions() {
func (m *extensionManager) UnloadAllExtensions() {
m.mu.Lock()
extensionIDs := make([]string, 0, len(m.extensions))
for id := range m.extensions {
@@ -1037,7 +1035,7 @@ func (m *ExtensionManager) UnloadAllExtensions() {
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()
defer m.mu.Unlock()
@@ -1055,15 +1053,29 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
}
defer ext.VMMu.Unlock()
// Merge extension return values onto the top-level JSON object so Flutter can read
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
// Handle promise - return pending status
return { success: true, pending: true, message: 'Action started' };
}
if (result !== null && result !== undefined && typeof result === 'object') {
var isArr = false;
if (typeof Array !== 'undefined' && Array.isArray) {
isArr = Array.isArray(result);
}
if (!isArr) {
var out = { success: true };
for (var k in result) {
out[k] = result[k];
}
return out;
}
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
-5
View File
@@ -105,7 +105,6 @@ type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
@@ -155,10 +154,6 @@ func (m *ExtensionManifest) Validate() error {
return &ManifestValidationError{Field: "version", Message: "version is required"}
}
if strings.TrimSpace(m.Author) == "" {
return &ManifestValidationError{Field: "author", Message: "author is required"}
}
if strings.TrimSpace(m.Description) == "" {
return &ManifestValidationError{Field: "description", Message: "description is required"}
}
File diff suppressed because it is too large Load Diff
+196 -12
View File
@@ -1,6 +1,10 @@
package gobackend
import "testing"
import (
"os"
"path/filepath"
"testing"
)
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
original := GetMetadataProviderPriority()
@@ -8,7 +12,7 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
SetMetadataProviderPriority([]string{"tidal"})
got := GetMetadataProviderPriority()
want := []string{"tidal", "deezer", "qobuz"}
want := []string{"tidal", "qobuz"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
@@ -19,6 +23,190 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
}
}
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
got := GetExtensionFallbackProviderIDs()
want := []string{"ext-a", "ext-b"}
if len(got) != len(want) {
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
}
}
}
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs(nil)
if !isExtensionFallbackAllowed("custom-ext") {
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
}
if !isExtensionFallbackAllowed("qobuz") {
t.Fatal("expected built-in provider to remain allowed")
}
}
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
if !isExtensionFallbackAllowed("allowed-ext") {
t.Fatal("expected explicitly allowed extension to be permitted")
}
if isExtensionFallbackAllowed("blocked-ext") {
t.Fatal("expected extension outside allowlist to be blocked")
}
if isExtensionFallbackAllowed("deezer") {
t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist")
}
}
func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
original := GetProviderPriority()
defer SetProviderPriority(original)
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
got := GetProviderPriority()
want := []string{"qobuz", "custom-ext", "tidal"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
}
}
}
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
if normalized == nil {
t.Fatal("expected legacy decryption key to produce normalized descriptor")
}
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
t.Fatalf("strategy = %q", normalized.Strategy)
}
if normalized.Key != "001122" {
t.Fatalf("key = %q", normalized.Key)
}
if normalized.InputFormat != "mov" {
t.Fatalf("input format = %q", normalized.InputFormat)
}
}
func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{
Strategy: "mp4_decryption_key",
Key: "abcd",
InputFormat: "",
}, "")
if normalized == nil {
t.Fatal("expected descriptor to remain available")
}
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
t.Fatalf("strategy = %q", normalized.Strategy)
}
if normalized.InputFormat != "mov" {
t.Fatalf("input format = %q", normalized.InputFormat)
}
}
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
SetAllowedDownloadDirs(nil)
outputDir := t.TempDir()
outputPath := buildOutputPath(DownloadRequest{
TrackName: "Song",
ArtistName: "Artist",
OutputDir: outputDir,
OutputExt: ".flac",
FilenameFormat: "",
})
if !isPathInAllowedDirs(outputPath) {
t.Fatalf("expected output path %q to be allowed", outputPath)
}
}
func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) {
SetAllowedDownloadDirs(nil)
outputDir := t.TempDir()
outputPath := filepath.Join(outputDir, "custom.flac")
ext := &loadedExtension{DataDir: t.TempDir()}
resolved := buildOutputPathForExtension(DownloadRequest{
OutputPath: outputPath,
}, ext)
if resolved != outputPath {
t.Fatalf("resolved output path = %q", resolved)
}
if !isPathInAllowedDirs(outputPath) {
t.Fatalf("expected output path %q to be allowed", outputPath)
}
}
func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
SetAllowedDownloadDirs(nil)
ext := &loadedExtension{DataDir: t.TempDir()}
resolved := buildOutputPathForExtension(DownloadRequest{
TrackName: "Song",
ArtistName: "Artist",
OutputDir: filepath.Join("Artist", "Album"),
OutputFD: 123,
OutputExt: ".flac",
}, ext)
expectedBase := filepath.Join(ext.DataDir, "downloads")
if !isPathWithinBase(expectedBase, resolved) {
t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved)
}
if !isPathInAllowedDirs(resolved) {
t.Fatalf("expected resolved output path %q to be allowed", resolved)
}
}
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "track.flac")
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
tempM4A := filepath.Join(t.TempDir(), "track.m4a")
if err := os.WriteFile(tempM4A, []byte("not-flac"), 0644); err != nil {
t.Fatalf("failed to create temp m4a file: %v", err)
}
if canEmbedGenreLabel("relative.flac") {
t.Fatal("expected relative path to be rejected")
}
if canEmbedGenreLabel("content://example") {
t.Fatal("expected content URI to be rejected")
}
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
t.Fatal("expected missing file to be rejected")
}
if canEmbedGenreLabel(tempM4A) {
t.Fatalf("expected non-FLAC file %q to be rejected", tempM4A)
}
if !canEmbedGenreLabel(tempFile) {
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
}
}
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
originalPriority := GetMetadataProviderPriority()
originalSearch := searchBuiltInMetadataTracksFunc
@@ -27,7 +215,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
searchBuiltInMetadataTracksFunc = originalSearch
}()
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
SetMetadataProviderPriority([]string{"qobuz", "tidal"})
var calls []string
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
@@ -42,27 +230,23 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
}, nil
case "deezer":
return []ExtTrackMetadata{
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
}, nil
default:
return nil, nil
}
}
manager := GetExtensionManager()
manager := getExtensionManager()
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
if err != nil {
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
}
if len(tracks) != 3 {
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
if len(tracks) != 2 {
t.Fatalf("unexpected track count: got %d want 2", len(tracks))
}
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" {
t.Fatalf("unexpected track provider order: %+v", tracks)
}
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
if len(calls) != 2 || calls[0] != "qobuz" || calls[1] != "tidal" {
t.Fatalf("unexpected provider call order: %v", calls)
}
}
+80 -10
View File
@@ -5,6 +5,7 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -80,7 +81,7 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
state.IsAuthenticated = accessToken != ""
}
type ExtensionRuntime struct {
type extensionRuntime struct {
extensionID string
manifest *ExtensionManifest
settings map[string]interface{}
@@ -123,10 +124,10 @@ var (
privateIPCacheMu sync.RWMutex
)
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
jar, _ := newSimpleCookieJar()
runtime := &ExtensionRuntime{
runtime := &extensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
@@ -136,31 +137,92 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
storageFlushDelay: defaultStorageFlushDelay,
}
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second))
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
return runtime
}
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Duration {
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
return fallback
}
raw, ok := ext.Manifest.Capabilities["networkTimeoutSeconds"]
if !ok {
return fallback
}
seconds := parseExtensionTimeoutSeconds(raw)
if seconds <= 0 {
return fallback
}
if seconds < 5 {
seconds = 5
}
if seconds > 300 {
seconds = 300
}
return time.Duration(seconds) * time.Second
}
func parseExtensionTimeoutSeconds(raw interface{}) int {
switch v := raw.(type) {
case int:
return v
case int32:
return int(v)
case int64:
return int(v)
case float32:
return int(v)
case float64:
return int(v)
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return parsed
default:
return 0
}
}
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
r.activeDownloadItemID = strings.TrimSpace(itemID)
}
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
func (r *extensionRuntime) clearActiveDownloadItemID() {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
r.activeDownloadItemID = ""
}
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
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 {
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
if req == nil {
return nil
}
itemID := r.getActiveDownloadItemID()
if itemID == "" {
return req
}
return req.WithContext(initDownloadCancel(itemID))
}
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
// spotify-web) will redirect http -> https and can end up in 301 loops.
@@ -329,11 +391,11 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
return j.cookies[u.Host]
}
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings
}
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
r.vm = vm
httpObj := vm.NewObject()
@@ -377,7 +439,9 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
fileObj.Set("exists", r.fileExists)
fileObj.Set("delete", r.fileDelete)
fileObj.Set("read", r.fileRead)
fileObj.Set("readBytes", r.fileReadBytes)
fileObj.Set("write", r.fileWrite)
fileObj.Set("writeBytes", r.fileWriteBytes)
fileObj.Set("copy", r.fileCopy)
fileObj.Set("move", r.fileMove)
fileObj.Set("getSize", r.fileGetSize)
@@ -407,8 +471,14 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("stringifyJSON", r.stringifyJSON)
utilsObj.Set("encrypt", r.cryptoEncrypt)
utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
utilsObj.Set("appVersion", r.appVersion)
utilsObj.Set("appUserAgent", r.appUserAgent)
utilsObj.Set("sleep", r.sleep)
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
vm.Set("utils", utilsObj)
logObj := vm.NewObject()
+11 -10
View File
@@ -52,7 +52,7 @@ func summarizeURLForLog(urlStr string) string {
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 {
return r.vm.ToValue(map[string]interface{}{
"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()
defer extensionAuthStateMu.RUnlock()
@@ -111,7 +111,7 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
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 {
return r.vm.ToValue(false)
}
@@ -149,7 +149,7 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
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()
delete(extensionAuthState, r.extensionID)
extensionAuthStateMu.Unlock()
@@ -162,7 +162,7 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
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()
defer extensionAuthStateMu.RUnlock()
@@ -178,7 +178,7 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
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()
defer extensionAuthStateMu.RUnlock()
@@ -228,7 +228,7 @@ func generatePKCEChallenge(verifier string) string {
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
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
@@ -265,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()
defer extensionAuthStateMu.RUnlock()
@@ -281,7 +281,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
})
}
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -385,7 +385,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
})
}
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -458,6 +458,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
+359
View File
@@ -0,0 +1,359 @@
package gobackend
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"github.com/dop251/goja"
"golang.org/x/crypto/blowfish"
)
type runtimeBlockCipherOptions struct {
Algorithm string
Mode string
Key []byte
IV []byte
InputEncoding string
OutputEncoding string
Padding string
}
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
if len(call.Arguments) <= index {
return nil
}
value := call.Arguments[index]
if goja.IsUndefined(value) || goja.IsNull(value) {
return nil
}
exported := value.Export()
if options, ok := exported.(map[string]interface{}); ok {
return options
}
return nil
}
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
if options == nil {
return defaultValue
}
raw, ok := options[key]
if !ok || raw == nil {
return defaultValue
}
switch value := raw.(type) {
case string:
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
case []byte:
if len(value) > 0 {
return string(value)
}
}
return defaultValue
}
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
if options == nil {
return defaultValue
}
raw, ok := options[key]
if !ok || raw == nil {
return defaultValue
}
switch value := raw.(type) {
case bool:
return value
case int:
return value != 0
case int64:
return value != 0
case float64:
return value != 0
case string:
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return defaultValue
}
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
if options == nil {
return defaultValue
}
raw, ok := options[key]
if !ok || raw == nil {
return defaultValue
}
switch value := raw.(type) {
case int:
return int64(value)
case int32:
return int64(value)
case int64:
return value
case float32:
return int64(value)
case float64:
return int64(value)
case string:
value = strings.TrimSpace(value)
if value == "" {
return defaultValue
}
var parsed int64
if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil {
return parsed
}
}
return defaultValue
}
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
if options == nil {
return false
}
_, exists := options[key]
return exists
}
func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "utf8", "utf-8", "text":
return []byte(input), nil
case "base64":
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(input))
if err != nil {
return nil, fmt.Errorf("invalid base64 data: %w", err)
}
return decoded, nil
case "hex":
decoded, err := hex.DecodeString(strings.TrimSpace(input))
if err != nil {
return nil, fmt.Errorf("invalid hex data: %w", err)
}
return decoded, nil
default:
return nil, fmt.Errorf("unsupported byte encoding: %s", encoding)
}
}
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
switch value := raw.(type) {
case string:
return decodeRuntimeBytesString(value, encoding)
case []byte:
cloned := make([]byte, len(value))
copy(cloned, value)
return cloned, nil
case []interface{}:
decoded := make([]byte, len(value))
for i, item := range value {
switch num := item.(type) {
case int:
decoded[i] = byte(num)
case int64:
decoded[i] = byte(num)
case float64:
decoded[i] = byte(int(num))
default:
return nil, fmt.Errorf("unsupported byte array item at index %d", i)
}
}
return decoded, nil
default:
return nil, fmt.Errorf("unsupported byte payload type")
}
}
func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "base64":
return base64.StdEncoding.EncodeToString(data), nil
case "hex":
return hex.EncodeToString(data), nil
case "utf8", "utf-8", "text":
return string(data), nil
default:
return "", fmt.Errorf("unsupported byte encoding: %s", encoding)
}
}
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
parsed := &runtimeBlockCipherOptions{
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
InputEncoding: strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64")),
OutputEncoding: strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64")),
Padding: strings.ToLower(runtimeOptionString(options, "padding", "none")),
}
if parsed.Algorithm == "" {
return nil, fmt.Errorf("algorithm is required")
}
if parsed.Mode == "" {
return nil, fmt.Errorf("mode is required")
}
key, err := decodeRuntimeBytesString(runtimeOptionString(options, "key", ""), runtimeOptionString(options, "keyEncoding", "utf8"))
if err != nil {
return nil, fmt.Errorf("invalid key: %w", err)
}
if len(key) == 0 {
return nil, fmt.Errorf("key is required")
}
parsed.Key = key
iv, err := decodeRuntimeBytesString(runtimeOptionString(options, "iv", ""), runtimeOptionString(options, "ivEncoding", "utf8"))
if err != nil {
return nil, fmt.Errorf("invalid iv: %w", err)
}
parsed.IV = iv
return parsed, nil
}
func newRuntimeBlockCipher(options *runtimeBlockCipherOptions) (cipher.Block, error) {
switch options.Algorithm {
case "blowfish":
return blowfish.NewCipher(options.Key)
case "aes":
return aes.NewCipher(options.Key)
default:
return nil, fmt.Errorf("unsupported block cipher algorithm: %s", options.Algorithm)
}
}
func applyPKCS7Padding(data []byte, blockSize int) []byte {
padding := blockSize - (len(data) % blockSize)
if padding == 0 {
padding = blockSize
}
out := make([]byte, len(data)+padding)
copy(out, data)
for i := len(data); i < len(out); i++ {
out[i] = byte(padding)
}
return out
}
func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, fmt.Errorf("invalid padded payload length")
}
padding := int(data[len(data)-1])
if padding <= 0 || padding > blockSize || padding > len(data) {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
for i := len(data) - padding; i < len(data); i++ {
if int(data[i]) != padding {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
}
return data[:len(data)-padding], nil
}
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "data and options are required",
})
}
options := parseRuntimeOptionsArgument(call, 1)
parsedOptions, err := parseRuntimeBlockCipherOptions(options)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
if parsedOptions.Mode != "cbc" {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
})
}
inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
block, err := newRuntimeBlockCipher(parsedOptions)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
if len(parsedOptions.IV) != block.BlockSize() {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
})
}
data := inputData
if !decrypt && parsedOptions.Padding == "pkcs7" {
data = applyPKCS7Padding(data, block.BlockSize())
}
if len(data)%block.BlockSize() != 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
})
}
output := make([]byte, len(data))
if decrypt {
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
if parsedOptions.Padding == "pkcs7" {
output, err = removePKCS7Padding(output, block.BlockSize())
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
}
} else {
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
}
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": encoded,
"block_size": block.BlockSize(),
})
}
func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value {
return r.transformBlockCipher(call, false)
}
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
return r.transformBlockCipher(call, true)
}
+185
View File
@@ -0,0 +1,185 @@
package gobackend
import (
"encoding/json"
"testing"
"github.com/dop251/goja"
)
func newBinaryTestRuntime(t *testing.T, withFilePermission bool) *goja.Runtime {
t.Helper()
ext := &loadedExtension{
ID: "binary-test-ext",
Manifest: &ExtensionManifest{
Name: "binary-test-ext",
Permissions: ExtensionPermissions{
File: withFilePermission,
},
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
vm := goja.New()
runtime.RegisterAPIs(vm)
return vm
}
func decodeJSONResult[T any](t *testing.T, value goja.Value) T {
t.Helper()
var decoded T
if err := json.Unmarshal([]byte(value.String()), &decoded); err != nil {
t.Fatalf("failed to decode JSON result: %v", err)
}
return decoded
}
func TestExtensionRuntime_FileByteAPIs(t *testing.T) {
vm := newBinaryTestRuntime(t, true)
result, err := vm.RunString(`
(function() {
var first = file.writeBytes("bytes.bin", "AAEC", {encoding: "base64", truncate: true});
if (!first.success) throw new Error(first.error);
var second = file.writeBytes("bytes.bin", "0304ff", {encoding: "hex", append: true});
if (!second.success) throw new Error(second.error);
var all = file.readBytes("bytes.bin", {encoding: "hex"});
if (!all.success) throw new Error(all.error);
var slice = file.readBytes("bytes.bin", {offset: 2, length: 2, encoding: "hex"});
if (!slice.success) throw new Error(slice.error);
var tail = file.readBytes("bytes.bin", {offset: 6, length: 4, encoding: "hex"});
if (!tail.success) throw new Error(tail.error);
return JSON.stringify({
all: all.data,
slice: slice.data,
size: all.size,
sliceBytes: slice.bytes_read,
sliceEof: slice.eof,
tailBytes: tail.bytes_read,
tailEof: tail.eof
});
})()
`)
if err != nil {
t.Fatalf("file byte APIs failed: %v", err)
}
decoded := decodeJSONResult[struct {
All string `json:"all"`
Slice string `json:"slice"`
Size int64 `json:"size"`
SliceBytes int `json:"sliceBytes"`
SliceEof bool `json:"sliceEof"`
TailBytes int `json:"tailBytes"`
TailEof bool `json:"tailEof"`
}](t, result)
if decoded.All != "0001020304ff" {
t.Fatalf("all = %q", decoded.All)
}
if decoded.Slice != "0203" {
t.Fatalf("slice = %q", decoded.Slice)
}
if decoded.Size != 6 {
t.Fatalf("size = %d", decoded.Size)
}
if decoded.SliceBytes != 2 {
t.Fatalf("slice bytes = %d", decoded.SliceBytes)
}
if decoded.SliceEof {
t.Fatal("slice should not be EOF")
}
if decoded.TailBytes != 0 || !decoded.TailEof {
t.Fatalf("tail read mismatch: bytes=%d eof=%v", decoded.TailBytes, decoded.TailEof)
}
}
func TestExtensionRuntime_BlockCipherCBCSupportsBlowfish(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "blowfish",
mode: "cbc",
key: "0123456789ABCDEFF0E1D2C3B4A59687",
keyEncoding: "hex",
iv: "0001020304050607",
ivEncoding: "hex",
inputEncoding: "hex",
outputEncoding: "hex",
padding: "none"
};
var enc = utils.encryptBlockCipher("00112233445566778899aabbccddeeff", options);
if (!enc.success) throw new Error(enc.error);
var dec = utils.decryptBlockCipher(enc.data, options);
if (!dec.success) throw new Error(dec.error);
return JSON.stringify({enc: enc.data, dec: dec.data});
})()
`)
if err != nil {
t.Fatalf("blowfish block cipher failed: %v", err)
}
decoded := decodeJSONResult[struct {
Enc string `json:"enc"`
Dec string `json:"dec"`
}](t, result)
if decoded.Dec != "00112233445566778899aabbccddeeff" {
t.Fatalf("dec = %q", decoded.Dec)
}
if decoded.Enc == decoded.Dec {
t.Fatal("expected ciphertext to differ from plaintext")
}
}
func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "aes",
mode: "cbc",
key: "000102030405060708090a0b0c0d0e0f",
keyEncoding: "hex",
iv: "0f0e0d0c0b0a09080706050403020100",
ivEncoding: "hex",
inputEncoding: "utf8",
outputEncoding: "base64",
padding: "pkcs7"
};
var enc = utils.encryptBlockCipher("hello generic cbc", options);
if (!enc.success) throw new Error(enc.error);
var dec = utils.decryptBlockCipher(enc.data, {
algorithm: "aes",
mode: "cbc",
key: options.key,
keyEncoding: options.keyEncoding,
iv: options.iv,
ivEncoding: options.ivEncoding,
inputEncoding: "base64",
outputEncoding: "utf8",
padding: "pkcs7"
});
if (!dec.success) throw new Error(dec.error);
return dec.data;
})()
`)
if err != nil {
t.Fatalf("aes block cipher failed: %v", err)
}
if result.String() != "hello generic cbc" {
t.Fatalf("unexpected decrypted value: %q", result.String())
}
}
+3 -3
View File
@@ -50,7 +50,7 @@ func ClearFFmpegCommand(commandID string) {
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 {
return r.vm.ToValue(map[string]interface{}{
"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 {
return r.vm.ToValue(map[string]interface{}{
"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 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
+209 -9
View File
@@ -71,7 +71,7 @@ func isPathWithinBase(baseDir, targetPath string) bool {
return true
}
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
func (r *extensionRuntime) validatePath(path string) (string, error) {
if !r.manifest.Permissions.File {
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
}
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -166,6 +166,7 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -271,7 +272,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 {
return r.vm.ToValue(false)
}
@@ -286,7 +287,7 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
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 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -315,7 +316,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 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -346,7 +347,105 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
})
}
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path is required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
options := parseRuntimeOptionsArgument(call, 1)
offset := runtimeOptionInt64(options, "offset", 0)
length := runtimeOptionInt64(options, "length", -1)
encoding := runtimeOptionString(options, "encoding", "base64")
if offset < 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "offset must be >= 0",
})
}
file, err := os.Open(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
size := info.Size()
if offset > size {
offset = size
}
if _, err := file.Seek(offset, io.SeekStart); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to seek file: %v", err),
})
}
var data []byte
switch {
case length == 0:
data = []byte{}
case length > 0:
buf := make([]byte, int(length))
n, readErr := file.Read(buf)
if readErr != nil && readErr != io.EOF {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read file: %v", readErr),
})
}
data = buf[:n]
default:
data, err = io.ReadAll(file)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read file: %v", err),
})
}
}
encoded, err := encodeRuntimeBytes(data, encoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": encoded,
"bytes_read": len(data),
"offset": offset,
"size": size,
"eof": offset+int64(len(data)) >= size,
})
}
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -386,7 +485,108 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
})
}
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) fileWriteBytes(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "path and data are required",
})
}
path := call.Arguments[0].String()
fullPath, err := r.validatePath(path)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
options := parseRuntimeOptionsArgument(call, 2)
appendMode := runtimeOptionBool(options, "append", false)
truncate := runtimeOptionBool(options, "truncate", false)
hasOffset := runtimeOptionHasKey(options, "offset")
offset := runtimeOptionInt64(options, "offset", 0)
encoding := runtimeOptionString(options, "encoding", "base64")
if appendMode && hasOffset {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "append and offset cannot be used together",
})
}
if offset < 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "offset must be >= 0",
})
}
data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to create directory: %v", err),
})
}
flags := os.O_CREATE | os.O_WRONLY
if appendMode {
flags |= os.O_APPEND
}
if truncate {
flags |= os.O_TRUNC
}
file, err := os.OpenFile(fullPath, flags, 0644)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
defer file.Close()
if hasOffset && !appendMode {
if _, err := file.Seek(offset, io.SeekStart); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to seek file: %v", err),
})
}
}
written, err := file.Write(data)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
info, statErr := file.Stat()
size := int64(0)
if statErr == nil {
size = info.Size()
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"path": fullPath,
"bytes_written": written,
"size": size,
})
}
func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -459,7 +659,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 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -507,7 +707,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 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
+13 -9
View File
@@ -17,7 +17,7 @@ type HTTPResponse struct {
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)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
@@ -49,7 +49,7 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
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 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
@@ -81,6 +81,7 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -124,7 +125,7 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
})
}
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
@@ -175,6 +176,7 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -221,7 +223,7 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
})
}
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
@@ -284,6 +286,7 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -330,19 +333,19 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
})
}
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
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)
}
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
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 {
return r.vm.ToValue(map[string]interface{}{
"error": "URL is required",
@@ -410,6 +413,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -455,7 +459,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
})
}
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 {
jar.mu.Lock()
jar.cookies = make(map[string][]*http.Cookie)
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"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 {
return r.vm.ToValue(0.0)
}
@@ -22,7 +22,7 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
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 {
return r.vm.ToValue(false)
}
@@ -43,7 +43,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
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 {
return r.vm.ToValue("")
}
+8 -7
View File
@@ -12,7 +12,7 @@ import (
"github.com/dop251/goja"
)
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.createFetchError("URL is required")
}
@@ -69,6 +69,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if err != nil {
return r.createFetchError(err.Error())
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -133,7 +134,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return responseObj
}
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
func (r *extensionRuntime) createFetchError(message string) goja.Value {
errorObj := r.vm.NewObject()
errorObj.Set("ok", false)
errorObj.Set("status", 0)
@@ -148,7 +149,7 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
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 {
return r.vm.ToValue("")
}
@@ -164,7 +165,7 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
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 {
return r.vm.ToValue("")
}
@@ -172,7 +173,7 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
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 {
encoder := call.This
encoder.Set("encoding", "utf-8")
@@ -252,7 +253,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 {
urlObj := call.This
@@ -416,7 +417,7 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
})
}
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
func (r *extensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
jsonScript := `
if (typeof JSON === 'undefined') {
var JSON = {
+23 -23
View File
@@ -21,7 +21,7 @@ const (
storageFlushRetryDelay = 2 * time.Second
)
func (r *ExtensionRuntime) getStoragePath() string {
func (r *extensionRuntime) getStoragePath() string {
return filepath.Join(r.dataDir, "storage.json")
}
@@ -36,7 +36,7 @@ func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
return dst
}
func (r *ExtensionRuntime) ensureStorageLoaded() error {
func (r *extensionRuntime) ensureStorageLoaded() error {
r.storageMu.RLock()
if r.storageLoaded {
r.storageMu.RUnlock()
@@ -74,7 +74,7 @@ func (r *ExtensionRuntime) ensureStorageLoaded() error {
return nil
}
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
if err := r.ensureStorageLoaded(); err != nil {
return nil, err
}
@@ -84,7 +84,7 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
return cloneInterfaceMap(r.storageCache), nil
}
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
if r.storageClosed {
return
}
@@ -94,7 +94,7 @@ func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
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)
if err != nil {
return err
@@ -106,13 +106,13 @@ func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}
return os.WriteFile(r.getStoragePath(), data, 0600)
}
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
func (r *extensionRuntime) flushStorageDirtyAsync() {
if err := r.flushStorageDirty(); err != nil {
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
}
}
func (r *ExtensionRuntime) flushStorageDirty() error {
func (r *extensionRuntime) flushStorageDirty() error {
r.storageMu.Lock()
if r.storageClosed {
r.storageTimer = nil
@@ -140,7 +140,7 @@ func (r *ExtensionRuntime) flushStorageDirty() error {
return nil
}
func (r *ExtensionRuntime) flushStorageNow() error {
func (r *extensionRuntime) flushStorageNow() error {
r.storageMu.Lock()
if r.storageTimer != nil {
r.storageTimer.Stop()
@@ -157,7 +157,7 @@ func (r *ExtensionRuntime) flushStorageNow() error {
return r.persistStorageSnapshot(snapshot)
}
func (r *ExtensionRuntime) closeStorageFlusher() {
func (r *extensionRuntime) closeStorageFlusher() {
r.storageMu.Lock()
r.storageClosed = true
r.storageDirty = false
@@ -168,7 +168,7 @@ func (r *ExtensionRuntime) closeStorageFlusher() {
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 {
return goja.Undefined()
}
@@ -193,7 +193,7 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.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 {
return r.vm.ToValue(false)
}
@@ -225,7 +225,7 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
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 {
return r.vm.ToValue(false)
}
@@ -254,15 +254,15 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true)
}
func (r *ExtensionRuntime) getCredentialsPath() string {
func (r *extensionRuntime) getCredentialsPath() string {
return filepath.Join(r.dataDir, ".credentials.enc")
}
func (r *ExtensionRuntime) getSaltPath() string {
func (r *extensionRuntime) getSaltPath() string {
return filepath.Join(r.dataDir, ".cred_salt")
}
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath()
salt, err := os.ReadFile(saltPath)
@@ -282,7 +282,7 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
return salt, nil
}
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
salt, err := r.getOrCreateSalt()
if err != nil {
return nil, err
@@ -293,7 +293,7 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
return hash[:], nil
}
func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
func (r *extensionRuntime) ensureCredentialsLoaded() error {
r.credentialsMu.RLock()
if r.credentialsLoaded {
r.credentialsMu.RUnlock()
@@ -340,7 +340,7 @@ func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
return nil
}
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
func (r *extensionRuntime) loadCredentials() (map[string]interface{}, error) {
if err := r.ensureCredentialsLoaded(); err != nil {
return nil, err
}
@@ -350,7 +350,7 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
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)
if err != nil {
return err
@@ -377,7 +377,7 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
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 {
return r.vm.ToValue(map[string]interface{}{
"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 {
return goja.Undefined()
}
@@ -439,7 +439,7 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.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 {
return r.vm.ToValue(false)
}
@@ -464,7 +464,7 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
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 {
return r.vm.ToValue(false)
}
+7 -7
View File
@@ -11,7 +11,7 @@ import (
"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()
result := runtime.storageSet(goja.FunctionCall{
Arguments: []goja.Value{
@@ -39,7 +39,7 @@ func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
}
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
ext := &LoadedExtension{
ext := &loadedExtension{
ID: "storage-test",
Manifest: &ExtensionManifest{
Name: "storage-test",
@@ -47,7 +47,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
runtime := newExtensionRuntime(ext)
runtime.storageFlushDelay = 25 * time.Millisecond
runtime.RegisterAPIs(goja.New())
@@ -86,7 +86,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
}
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
ext := &LoadedExtension{
ext := &loadedExtension{
ID: "unload-storage-test",
Manifest: &ExtensionManifest{
Name: "unload-storage-test",
@@ -95,13 +95,13 @@ func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
VM: goja.New(),
}
runtime := NewExtensionRuntime(ext)
runtime := newExtensionRuntime(ext)
runtime.storageFlushDelay = time.Hour
runtime.RegisterAPIs(ext.VM)
ext.runtime = runtime
manager := &ExtensionManager{
extensions: map[string]*LoadedExtension{
manager := &extensionManager{
extensions: map[string]*loadedExtension{
ext.ID: ext,
},
}
+83 -20
View File
@@ -16,7 +16,7 @@ import (
"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 {
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)))
}
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
}
@@ -36,7 +36,7 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
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 {
return r.vm.ToValue("")
}
@@ -45,7 +45,7 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
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 {
return r.vm.ToValue("")
}
@@ -54,7 +54,7 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
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 {
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)))
}
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
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)))
}
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue([]byte{})
}
@@ -130,7 +130,7 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
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 {
return goja.Undefined()
}
@@ -145,7 +145,7 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
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 {
return r.vm.ToValue("")
}
@@ -160,7 +160,7 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
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 {
return r.vm.ToValue(map[string]interface{}{
"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 {
return r.vm.ToValue(map[string]interface{}{
"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
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
if l, ok := call.Arguments[0].Export().(float64); ok {
@@ -245,35 +245,98 @@ 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())
}
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) appVersion(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(GetAppVersion())
}
func (r *extensionRuntime) appUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(appUserAgent())
}
func (r *extensionRuntime) sleep(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(true)
}
sleepMs := 0
switch value := call.Arguments[0].Export().(type) {
case int64:
sleepMs = int(value)
case int32:
sleepMs = int(value)
case int:
sleepMs = value
case float64:
sleepMs = int(value)
default:
sleepMs = 0
}
if sleepMs <= 0 {
return r.vm.ToValue(true)
}
if sleepMs > 5*60*1000 {
sleepMs = 5 * 60 * 1000
}
itemID := r.getActiveDownloadItemID()
deadline := time.Now().Add(time.Duration(sleepMs) * time.Millisecond)
for {
if itemID != "" && isDownloadCancelled(itemID) {
return r.vm.ToValue(false)
}
remaining := time.Until(deadline)
if remaining <= 0 {
return r.vm.ToValue(true)
}
step := 100 * time.Millisecond
if remaining < step {
step = remaining
}
time.Sleep(step)
}
}
func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Value {
itemID := r.getActiveDownloadItemID()
if itemID == "" {
return r.vm.ToValue(false)
}
return r.vm.ToValue(isDownloadCancelled(itemID))
}
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
func (r *extensionRuntime) logError(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
return goja.Undefined()
}
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
parts := make([]string, len(args))
for i, arg := range args {
parts[i] = fmt.Sprintf("%v", arg.Export())
@@ -281,7 +344,7 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
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 {
return r.vm.ToValue("")
}
@@ -289,7 +352,7 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
return r.vm.ToValue(sanitizeFilename(input))
}
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
gobackendObj := vm.Get("gobackend")
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
gobackendObj = vm.NewObject()
+20 -8
View File
@@ -26,7 +26,6 @@ type storeExtension struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"`
@@ -83,7 +82,6 @@ type storeExtensionResponse struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url"`
IconURL string `json:"icon_url,omitempty"`
@@ -103,7 +101,6 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
Name: e.Name,
DisplayName: e.getDisplayName(),
Version: e.Version,
Author: e.Author,
Description: e.Description,
DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(),
@@ -253,7 +250,17 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
client := NewHTTPClientWithTimeout(30 * time.Second)
resp, err := client.Get(s.registryURL)
req, err := http.NewRequest(http.MethodGet, s.registryURL, nil)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionStore", "Failed to build registry request, using cached registry: %v", err)
return s.cache, nil
}
return nil, fmt.Errorf("failed to build registry request: %w", err)
}
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
@@ -295,7 +302,7 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
return nil, err
}
manager := GetExtensionManager()
manager := getExtensionManager()
installed := make(map[string]string) // id -> version
if manager != nil {
@@ -348,7 +355,13 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := NewHTTPClientWithTimeout(5 * time.Minute)
resp, err := client.Get(ext.getDownloadURL())
req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
if err != nil {
return fmt.Errorf("failed to build download request: %w", err)
}
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
@@ -481,8 +494,7 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
if query != "" {
if !containsIgnoreCase(ext.Name, queryLower) &&
!containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) &&
!containsIgnoreCase(ext.Author, queryLower) {
!containsIgnoreCase(ext.Description, queryLower) {
found := false
for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) {
+134 -13
View File
@@ -1,8 +1,10 @@
package gobackend
import (
"net/http"
"path/filepath"
"testing"
"time"
"github.com/dop251/goja"
)
@@ -12,7 +14,6 @@ func TestParseManifest_Valid(t *testing.T) {
"name": "test-provider",
"displayName": "Test Provider",
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension",
"type": ["metadata_provider"],
"permissions": {
@@ -46,7 +47,6 @@ func TestParseManifest_Valid(t *testing.T) {
func TestParseManifest_MissingName(t *testing.T) {
invalidManifest := `{
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension",
"type": ["metadata_provider"]
}`
@@ -61,7 +61,6 @@ func TestParseManifest_MissingType(t *testing.T) {
invalidManifest := `{
"name": "test-provider",
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension"
}`
@@ -99,7 +98,7 @@ func TestIsDomainAllowed(t *testing.T) {
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
// Create a mock extension with limited network permissions
ext := &LoadedExtension{
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
@@ -110,7 +109,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
runtime := newExtensionRuntime(ext)
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
@@ -132,7 +131,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
func TestExtensionRuntime_FileSandbox(t *testing.T) {
tempDir := t.TempDir()
ext := &LoadedExtension{
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
@@ -143,7 +142,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
DataDir: tempDir,
}
runtime := NewExtensionRuntime(ext)
runtime := newExtensionRuntime(ext)
validPath, err := runtime.validatePath("test.txt")
if err != nil {
@@ -177,7 +176,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
t.Error("Expected absolute path to be blocked")
}
extNoFile := &LoadedExtension{
extNoFile := &loadedExtension{
ID: "test-ext-no-file",
Manifest: &ExtensionManifest{
Name: "test-ext-no-file",
@@ -187,7 +186,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
},
DataDir: tempDir,
}
runtimeNoFile := NewExtensionRuntime(extNoFile)
runtimeNoFile := newExtensionRuntime(extNoFile)
_, err = runtimeNoFile.validatePath("test.txt")
if err == nil {
t.Error("Expected file access to be denied without file permission")
@@ -195,7 +194,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
}
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
ext := &LoadedExtension{
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
@@ -203,7 +202,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
runtime := newExtensionRuntime(ext)
vm := goja.New()
runtime.RegisterAPIs(vm)
@@ -239,11 +238,133 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
if result.String() == "" {
t.Error("Expected non-empty JSON string")
}
result, err = vm.RunString(`utils.sleep(1)`)
if err != nil {
t.Fatalf("sleep failed: %v", err)
}
if !result.ToBoolean() {
t.Error("Expected sleep to complete successfully")
}
runtime.setActiveDownloadItemID("test-item")
cancelDownload("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
result, err = vm.RunString(`utils.isDownloadCancelled()`)
if err != nil {
t.Fatalf("isDownloadCancelled failed: %v", err)
}
if !result.ToBoolean() {
t.Error("Expected active download cancellation to be visible to JS")
}
SetAppVersion("4.2.2")
t.Cleanup(func() {
SetAppVersion("")
})
result, err = vm.RunString(`utils.appVersion()`)
if err != nil {
t.Fatalf("appVersion failed: %v", err)
}
if got := result.String(); got != "4.2.2" {
t.Fatalf("Expected appVersion 4.2.2, got %q", got)
}
result, err = vm.RunString(`utils.appUserAgent()`)
if err != nil {
t.Fatalf("appUserAgent failed: %v", err)
}
if got := result.String(); got != "SpotiFLAC-Mobile/4.2.2" {
t.Fatalf("Expected appUserAgent SpotiFLAC-Mobile/4.2.2, got %q", got)
}
result, err = vm.RunString(`utils.sleep(50)`)
if err != nil {
t.Fatalf("cancel-aware sleep failed: %v", err)
}
if result.ToBoolean() {
t.Error("Expected sleep to abort when download is cancelled")
}
}
func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
cancelDownload("test-item")
select {
case <-req.Context().Done():
case <-time.After(500 * time.Millisecond):
t.Fatal("Expected bound request context to be cancelled")
}
if req.Context().Err() == nil {
t.Fatal("Expected request context error after cancellation")
}
}
func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
cancelDownload("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
select {
case <-req.Context().Done():
case <-time.After(500 * time.Millisecond):
t.Fatal("Expected pre-cancelled request context to stay cancelled")
}
if req.Context().Err() == nil {
t.Fatal("Expected request context error for pre-cancelled item")
}
}
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
// Create extension with limited network permissions
ext := &LoadedExtension{
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
@@ -254,7 +375,7 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
runtime := newExtensionRuntime(ext)
privateIPs := []string{
"http://localhost/admin",
+2 -2
View File
@@ -53,7 +53,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
IsTimeout: true,
}}
} 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)}
}
}
@@ -90,7 +90,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
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")
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)",
IsTimeout: true,
+29 -4
View File
@@ -6,6 +6,8 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
var (
@@ -17,19 +19,42 @@ var (
)
func sanitizeFilename(filename string) string {
sanitized := invalidChars.ReplaceAllString(filename, "_")
sanitized := strings.ReplaceAll(filename, "/", " ")
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
var builder strings.Builder
for _, r := range sanitized {
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
if r == 0x7F {
continue
}
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
builder.WriteRune(r)
}
sanitized = builder.String()
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ".")
sanitized = strings.Trim(sanitized, ". ")
sanitized = strings.Join(strings.Fields(sanitized), " ")
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
sanitized = strings.Trim(sanitized, "_ ")
if !utf8.ValidString(sanitized) {
sanitized = strings.ToValidUTF8(sanitized, "_")
}
if len(sanitized) > 200 {
sanitized = sanitized[:200]
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
sanitized = strings.Trim(sanitized, "_ ")
}
if sanitized == "" {
sanitized = "untitled"
return "Unknown"
}
return sanitized
+15
View File
@@ -83,3 +83,18 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
func TestSanitizeFilenameMatchesDesktopSpacingBehavior(t *testing.T) {
got := sanitizeFilename(` "Text In Quotes"?%* / Demo `)
want := "Text In Quotes % Demo"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
got := sanitizeFilename(`<>:"/\|?*`)
if got != "Unknown" {
t.Fatalf("expected %q, got %q", "Unknown", got)
}
}
+15 -2
View File
@@ -16,6 +16,19 @@ import (
"time"
)
func userAgentForURL(u *url.URL) string {
if u == nil {
return getRandomUserAgent()
}
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
if host == "api.zarz.moe" {
return appUserAgent()
}
return getRandomUserAgent()
}
func getRandomUserAgent() string {
chromeVersion := rand.Intn(26) + 120
chromeBuild := rand.Intn(1500) + 6000
@@ -225,7 +238,7 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
}
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := client.Do(req)
if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
@@ -255,7 +268,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
resp, err := client.Do(reqCopy)
if err != nil {
+1 -1
View File
@@ -11,7 +11,7 @@ func GetCloudflareBypassClient() *http.Client {
}
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := sharedClient.Do(req)
if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
+3 -3
View File
@@ -101,7 +101,7 @@ func GetCloudflareBypassClient() *http.Client {
}
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := sharedClient.Do(req)
if err == nil {
@@ -129,7 +129,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
return cloudflareBypassClient.Do(reqCopy)
}
@@ -155,7 +155,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
return cloudflareBypassClient.Do(reqCopy)
}
+30
View File
@@ -24,13 +24,18 @@ type LibraryScanResult struct {
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
ISRC string `json:"isrc,omitempty"`
TrackNumber int `json:"trackNumber,omitempty"`
TotalTracks int `json:"totalTracks,omitempty"`
DiscNumber int `json:"discNumber,omitempty"`
TotalDiscs int `json:"totalDiscs,omitempty"`
Duration int `json:"duration,omitempty"`
ReleaseDate string `json:"releaseDate,omitempty"`
BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,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"`
}
@@ -365,9 +370,14 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
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.ReleaseDate = metadata.Date
result.Genre = metadata.Genre
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetAudioQuality(filePath)
if err == nil {
@@ -397,12 +407,17 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
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.ReleaseDate = metadata.Date
if result.ReleaseDate == "" {
result.ReleaseDate = metadata.Year
}
result.Genre = metadata.Genre
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
}
quality, err := GetM4AQuality(filePath)
@@ -427,7 +442,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
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
@@ -435,6 +452,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
result.ReleaseDate = metadata.Year
}
result.ISRC = metadata.ISRC
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetMP3Quality(filePath)
if err == nil {
@@ -464,9 +484,14 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
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
result.ReleaseDate = metadata.Date
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
quality, err := GetOggQuality(filePath)
if err == nil {
@@ -501,13 +526,18 @@ func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint str
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)
+28 -2
View File
@@ -39,8 +39,34 @@ var DefaultLyricsProviders = []string{
var (
lyricsProvidersMu sync.RWMutex
lyricsProviders []string // ordered list of enabled providers
appVersionMu sync.RWMutex
appVersion string
)
func SetAppVersion(version string) {
normalized := strings.TrimSpace(version)
appVersionMu.Lock()
defer appVersionMu.Unlock()
appVersion = normalized
}
func GetAppVersion() string {
appVersionMu.RLock()
defer appVersionMu.RUnlock()
return appVersion
}
func appUserAgent() string {
version := GetAppVersion()
if version == "" {
return "SpotiFLAC-Mobile"
}
return "SpotiFLAC-Mobile/" + version
}
type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"`
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
@@ -385,8 +411,8 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
primaryArtist := normalizeArtistName(artistName)
fetchOptions := GetLyricsFetchOptions()
extManager := GetExtensionManager()
var extensionProviders []*ExtensionProviderWrapper
extManager := getExtensionManager()
var extensionProviders []*extensionProviderWrapper
if extManager != nil {
extensionProviders = extManager.GetLyricsProviders()
}
+3 -2
View File
@@ -114,7 +114,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
@@ -147,7 +147,8 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
+1 -1
View File
@@ -72,7 +72,7 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
+2 -2
View File
@@ -70,7 +70,7 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
for k, v := range neteaseHeaders {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -109,7 +109,7 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
for k, v := range neteaseHeaders {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
+1 -1
View File
@@ -54,7 +54,7 @@ func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, dura
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
+416 -29
View File
@@ -9,6 +9,7 @@ import (
_ "image/jpeg"
_ "image/png"
"io"
"math"
"os"
"path/filepath"
"regexp"
@@ -110,6 +111,7 @@ type Metadata struct {
TrackNumber int
TotalTracks int
DiscNumber int
TotalDiscs int
ISRC string
Description string
Lyrics string
@@ -273,23 +275,23 @@ func ReadMetadata(filePath string) (*Metadata, error) {
trackNum := getComment(cmt, "TRACKNUMBER")
if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
}
if metadata.TrackNumber == 0 {
trackNum = getComment(cmt, "TRACK")
if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
}
}
discNum := getComment(cmt, "DISCNUMBER")
if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
}
if metadata.DiscNumber == 0 {
discNum = getComment(cmt, "DISC")
if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
}
}
@@ -403,26 +405,39 @@ func EditFlacFields(filePath string, fields map[string]string) error {
removeCommentKey(cmt, "ALBUM_ARTIST")
}
// Track/disc numbers: present + empty → clear; present + "0" → clear.
if v, ok := fields["track_number"]; ok {
trackNum := 0
if v != "" {
fmt.Sscanf(v, "%d", &trackNum)
// 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 trackNum > 0 {
setOrClearComment(cmt, "TRACKNUMBER", strconv.Itoa(trackNum))
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 v, ok := fields["disc_number"]; ok {
discNum := 0
if v != "" {
fmt.Sscanf(v, "%d", &discNum)
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 discNum > 0 {
setOrClearComment(cmt, "DISCNUMBER", strconv.Itoa(discNum))
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")
}
@@ -478,15 +493,11 @@ func writeVorbisMetadata(cmt *flacvorbis.MetaDataBlockVorbisComment, metadata Me
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))
}
setComment(cmt, "TRACKNUMBER", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
}
if metadata.DiscNumber > 0 {
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
setComment(cmt, "DISCNUMBER", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
}
if metadata.ISRC != "" {
@@ -953,9 +964,9 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) {
case "\xa9lyr":
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
case "trkn":
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fi.Size())
case "disk":
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fi.Size())
case "----":
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
if freeformErr == nil {
@@ -1150,6 +1161,41 @@ func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, erro
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) {
start := parent.offset + parent.headerSize
end := parent.offset + parent.size
@@ -1199,6 +1245,281 @@ func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string
return nameValue, dataValue, nil
}
type m4aMetadataPath struct {
moov atomHeader
udta *atomHeader
meta atomHeader
ilst atomHeader
}
func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return m4aMetadataPath{}, fmt.Errorf("moov not found")
}
moovBodyStart := moov.offset + moov.headerSize
moovBodySize := moov.size - moov.headerSize
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
udtaCopy := udta
return m4aMetadataPath{
moov: moov,
udta: &udtaCopy,
meta: meta,
ilst: ilst,
}, nil
}
}
}
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
return m4aMetadataPath{
moov: moov,
meta: meta,
ilst: ilst,
}, nil
}
}
return m4aMetadataPath{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
}
func buildM4AAtom(typ string, payload []byte) []byte {
size := int64(8 + len(payload))
buf := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(buf[0:4], uint32(size))
copy(buf[4:8], []byte(typ))
copy(buf[8:], payload)
return buf
}
func buildM4AFreeformAtom(name, value string) []byte {
meanPayload := append([]byte{0, 0, 0, 0}, []byte("com.apple.iTunes")...)
namePayload := append([]byte{0, 0, 0, 0}, []byte(name)...)
dataPayload := make([]byte, 8+len(value))
binary.BigEndian.PutUint32(dataPayload[0:4], 1) // UTF-8 text
copy(dataPayload[8:], []byte(value))
payload := append([]byte{}, buildM4AAtom("mean", meanPayload)...)
payload = append(payload, buildM4AAtom("name", namePayload)...)
payload = append(payload, buildM4AAtom("data", dataPayload)...)
return buildM4AAtom("----", payload)
}
func buildITunNORMTag(trackGain, trackPeak string) string {
gainDb, ok := parseReplayGainDb(trackGain)
if !ok {
return ""
}
peakLinear, ok := parseReplayGainPeak(trackPeak)
if !ok {
return ""
}
clamp := func(v int64) int64 {
if v < 0 {
return 0
}
if v > 65534 {
return 65534
}
return v
}
g1 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 1000.0)))
g2 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 2500.0)))
peak := clamp(int64(math.Round(peakLinear * 32768.0)))
values := []int64{g1, g1, g2, g2, 0, 0, peak, peak, 0, 0}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strings.ToUpper(fmt.Sprintf("%08x", value)))
}
return strings.Join(parts, " ")
}
func parseReplayGainDb(value string) (float64, bool) {
match := regexp.MustCompile(`([+-]?\d+(?:\.\d+)?)`).FindStringSubmatch(strings.TrimSpace(value))
if len(match) < 2 {
return 0, false
}
parsed, err := strconv.ParseFloat(match[1], 64)
if err != nil {
return 0, false
}
return parsed, true
}
func parseReplayGainPeak(value string) (float64, bool) {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || parsed <= 0 {
return 0, false
}
return parsed, true
}
func collectM4AReplayGainFields(fields map[string]string) map[string]string {
result := map[string]string{}
if value := strings.TrimSpace(fields["replaygain_track_gain"]); value != "" {
result["replaygain_track_gain"] = value
}
if value := strings.TrimSpace(fields["replaygain_track_peak"]); value != "" {
result["replaygain_track_peak"] = value
}
if value := strings.TrimSpace(fields["replaygain_album_gain"]); value != "" {
result["replaygain_album_gain"] = value
}
if value := strings.TrimSpace(fields["replaygain_album_peak"]); value != "" {
result["replaygain_album_peak"] = value
}
if norm := buildITunNORMTag(result["replaygain_track_gain"], result["replaygain_track_peak"]); norm != "" {
result["iTunNORM"] = norm
}
return result
}
func writeAtomSize(buf []byte, header atomHeader, newSize int64) error {
if newSize <= 0 {
return fmt.Errorf("invalid size for %s", header.typ)
}
if header.headerSize == 16 {
if int(header.offset)+16 > len(buf) {
return io.ErrUnexpectedEOF
}
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], 1)
binary.BigEndian.PutUint64(buf[header.offset+8:header.offset+16], uint64(newSize))
return nil
}
if newSize > math.MaxUint32 {
return fmt.Errorf("atom %s too large for 32-bit header", header.typ)
}
if int(header.offset)+8 > len(buf) {
return io.ErrUnexpectedEOF
}
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], uint32(newSize))
return nil
}
func EditM4AReplayGain(filePath string, fields map[string]string) error {
replayGainFields := collectM4AReplayGainFields(fields)
if len(replayGainFields) == 0 {
return nil
}
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
path, err := findM4AMetadataPath(f, info.Size())
if err != nil {
return err
}
data, err := os.ReadFile(filePath)
if err != nil {
return err
}
bodyStart := path.ilst.offset + path.ilst.headerSize
bodyEnd := path.ilst.offset + path.ilst.size
newBody := make([]byte, 0, int(path.ilst.size))
targets := map[string]struct{}{
"REPLAYGAIN_TRACK_GAIN": {},
"REPLAYGAIN_TRACK_PEAK": {},
"REPLAYGAIN_ALBUM_GAIN": {},
"REPLAYGAIN_ALBUM_PEAK": {},
"ITUNNORM": {},
}
for pos := bodyStart; pos+8 <= bodyEnd; {
header, readErr := readAtomHeaderAt(f, pos, info.Size())
if readErr != nil {
return readErr
}
if header.size == 0 {
header.size = bodyEnd - pos
}
if header.size < header.headerSize {
return fmt.Errorf("invalid atom size for %s", header.typ)
}
keep := true
if header.typ == "----" {
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
if freeformErr == nil {
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
keep = false
}
}
}
if keep {
newBody = append(newBody, data[pos:pos+header.size]...)
}
pos += header.size
}
order := []string{
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
"iTunNORM",
}
for _, key := range order {
value := strings.TrimSpace(replayGainFields[key])
if value == "" {
continue
}
name := key
if key != "iTunNORM" {
name = strings.ToLower(key)
}
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
}
newIlst := buildM4AAtom("ilst", newBody)
updated := append([]byte{}, data[:path.ilst.offset]...)
updated = append(updated, newIlst...)
updated = append(updated, data[path.ilst.offset+path.ilst.size:]...)
delta := int64(len(newIlst)) - path.ilst.size
if err := writeAtomSize(updated, path.ilst, path.ilst.size+delta); err != nil {
return err
}
if err := writeAtomSize(updated, path.meta, path.meta.size+delta); err != nil {
return err
}
if path.udta != nil {
if err := writeAtomSize(updated, *path.udta, path.udta.size+delta); err != nil {
return err
}
}
if err := writeAtomSize(updated, path.moov, path.moov.size+delta); err != nil {
return err
}
return os.WriteFile(filePath, updated, 0o644)
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
@@ -1378,16 +1699,82 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
// [28:32] samplerate (16.16 fixed-point)
sampleRate := int(buf[28])<<8 | int(buf[29])
bitDepth := int(buf[22])<<8 | int(buf[23])
if bitDepth <= 0 {
bitDepth = 16
if atomType == "alac" {
bitDepth = 24
if atomType == "alac" {
if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok {
if alacBitDepth > 0 {
bitDepth = alacBitDepth
}
if alacSampleRate > 0 {
sampleRate = alacSampleRate
}
}
}
if bitDepth <= 0 {
bitDepth = 16
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
}
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
if sampleOffset < 4 {
return 0, 0, false
}
sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize)
if err != nil {
return 0, 0, false
}
childStart := sampleOffset + 32
childEnd := sampleEntryHeader.offset + sampleEntryHeader.size
if childStart >= childEnd {
return 0, 0, false
}
configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "alac", fileSize)
if err != nil || !found {
return 0, 0, false
}
payloadSize := configHeader.size - configHeader.headerSize
if payloadSize <= 0 {
return 0, 0, false
}
payload := make([]byte, payloadSize)
if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil {
return 0, 0, false
}
return parseALACSpecificConfig(payload)
}
func parseALACSpecificConfig(payload []byte) (int, int, bool) {
if len(payload) < 24 {
return 0, 0, false
}
bitDepth := int(payload[5])
sampleRate := int(binary.BigEndian.Uint32(payload[20:24]))
if bitDepth > 0 && sampleRate > 0 {
return bitDepth, sampleRate, true
}
// Some encoders prepend 4 bytes before the ALACSpecificConfig payload.
if len(payload) >= 28 {
bitDepth = int(payload[9])
sampleRate = int(binary.BigEndian.Uint32(payload[24:28]))
if bitDepth > 0 && sampleRate > 0 {
return bitDepth, sampleRate, true
}
}
return 0, 0, false
}
type atomHeader struct {
offset int64
size int64
+49
View File
@@ -0,0 +1,49 @@
package gobackend
import "testing"
func TestParseALACSpecificConfigStandardPayload(t *testing.T) {
payload := make([]byte, 24)
payload[5] = 24
payload[20] = 0x00
payload[21] = 0x00
payload[22] = 0xac
payload[23] = 0x44
bitDepth, sampleRate, ok := parseALACSpecificConfig(payload)
if !ok {
t.Fatal("expected standard ALAC payload to parse")
}
if bitDepth != 24 {
t.Fatalf("bitDepth = %d, want 24", bitDepth)
}
if sampleRate != 44100 {
t.Fatalf("sampleRate = %d, want 44100", sampleRate)
}
}
func TestParseALACSpecificConfigPayloadWithLeadingFourBytes(t *testing.T) {
payload := make([]byte, 28)
payload[9] = 16
payload[24] = 0x00
payload[25] = 0x00
payload[26] = 0xbb
payload[27] = 0x80
bitDepth, sampleRate, ok := parseALACSpecificConfig(payload)
if !ok {
t.Fatal("expected offset ALAC payload to parse")
}
if bitDepth != 16 {
t.Fatalf("bitDepth = %d, want 16", bitDepth)
}
if sampleRate != 48000 {
t.Fatalf("sampleRate = %d, want 48000", sampleRate)
}
}
func TestParseALACSpecificConfigRejectsShortPayload(t *testing.T) {
if _, _, ok := parseALACSpecificConfig(make([]byte, 12)); ok {
t.Fatal("expected short ALAC payload to be rejected")
}
}
+4
View File
@@ -23,11 +23,13 @@ type TrackMetadata struct {
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 {
@@ -42,11 +44,13 @@ type AlbumTrackMetadata struct {
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 {
+19 -10
View File
@@ -55,6 +55,7 @@ const (
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
qobuzZarzDownloadAPIURL = "https://api.zarz.moe/dl/qbz"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
@@ -105,6 +106,10 @@ type QobuzTrack struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"performer"`
Composer struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"composer"`
}
type qobuzImageSet struct {
@@ -349,6 +354,7 @@ func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata {
AlbumID: qobuzPrefixedID(track.Album.ID),
ArtistID: qobuzTrackArtistID(track),
AlbumType: qobuzTrackAlbumType(track),
Composer: strings.TrimSpace(track.Composer.Name),
}
}
@@ -373,6 +379,7 @@ func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata {
AlbumID: qobuzPrefixedID(track.Album.ID),
AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)),
AlbumType: qobuzTrackAlbumType(track),
Composer: strings.TrimSpace(track.Composer.Name),
}
}
@@ -1030,6 +1037,7 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
}
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
totalDiscs := 0
for i := range album.Tracks.Items {
track := &album.Tracks.Items[i]
track.Album.ID = album.ID
@@ -1041,8 +1049,14 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
Large: album.Image.Large,
}
track.Album.TracksCount = album.TracksCount
if track.MediaNumber > totalDiscs {
totalDiscs = track.MediaNumber
}
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
}
for i := range tracks {
tracks[i].TotalDiscs = totalDiscs
}
return &AlbumResponsePayload{
AlbumInfo: qobuzAlbumToAlbumInfo(album),
@@ -1126,6 +1140,7 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
func (q *QobuzDownloader) GetAvailableAPIs() []string {
return []string{
qobuzDownloadAPIURL,
qobuzZarzDownloadAPIURL,
qobuzDabMusicAPIURL,
qobuzDeebAPIURL,
qobuzAfkarAPIURL,
@@ -1147,6 +1162,7 @@ const (
func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
return []qobuzAPIProvider{
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
{Name: "zarz", URL: qobuzZarzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
@@ -2639,17 +2655,8 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
}
}
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
if track == nil {
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search", false) {
track = nil
}
}
if track == nil {
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
errMsg := "could not find matching track on Qobuz without identifier match"
if err != nil {
errMsg = err.Error()
}
@@ -2793,10 +2800,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
TotalDiscs: req.TotalDiscs,
ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Composer: req.Composer,
}
var coverData []byte
+44 -11
View File
@@ -241,12 +241,13 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
func TestQobuzAvailableProviders(t *testing.T) {
providers := NewQobuzDownloader().GetAvailableProviders()
if len(providers) != 5 {
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
if len(providers) != 6 {
t.Fatalf("expected 6 Qobuz providers, got %d", len(providers))
}
want := map[string]string{
"musicdl": qobuzAPIKindMusicDL,
"zarz": qobuzAPIKindMusicDL,
"dabmusic": qobuzAPIKindStandard,
"deeb": qobuzAPIKindStandard,
"qbz": qobuzAPIKindStandard,
@@ -428,11 +429,9 @@ func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
t.Fatal("ISRC fallback should not run without an ISRC")
return nil, nil
}
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 {
t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec)
}
return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
t.Fatal("metadata fallback should not run")
return nil, nil
}
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
t.Fatal("SongLink should not run when Odesli QobuzID is provided")
@@ -447,11 +446,11 @@ func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
}
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
if err != nil {
t.Fatalf("expected no error, got %v", err)
if err == nil {
t.Fatalf("expected error, got track %+v", track)
}
if track == nil || track.ID != 444 || track.Title != "Taste Back" {
t.Fatalf("unexpected resolved track: %+v", track)
if track != nil {
t.Fatalf("expected nil track, got %+v", track)
}
}
@@ -518,3 +517,37 @@ func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
}
}
func TestQobuzTrackMetadataIncludesComposer(t *testing.T) {
track := &QobuzTrack{
ID: 40681594,
Title: "Sign of the Times",
ISRC: "USSM11703595",
Duration: 340,
TrackNumber: 1,
MediaNumber: 1,
}
track.Performer.ID = 729886
track.Performer.Name = "Harry Styles"
track.Composer.ID = 729886
track.Composer.Name = "Harry Styles"
track.Album.ID = "0886446451985"
track.Album.Title = "Harry Styles"
track.Album.ReleaseDate = "2017-05-12"
track.Album.TracksCount = 10
track.Album.ReleaseType = "album"
track.Album.ProductType = "album"
track.Album.Artist.ID = 729886
track.Album.Artist.Name = "Harry Styles"
track.Album.Artists = []qobuzArtistRef{{ID: 729886, Name: "Harry Styles"}}
trackMeta := qobuzTrackToTrackMetadata(track)
if trackMeta.Composer != "Harry Styles" {
t.Fatalf("track composer = %q", trackMeta.Composer)
}
albumTrackMeta := qobuzTrackToAlbumTrackMetadata(track)
if albumTrackMeta.Composer != "Harry Styles" {
t.Fatalf("album track composer = %q", albumTrackMeta.Composer)
}
}
+4 -3
View File
@@ -147,6 +147,7 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
return nil, fmt.Errorf("failed to create resolve request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := s.client.Do(req)
if err != nil {
@@ -164,9 +165,9 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
}
var resolveResp struct {
Success bool `json:"success"`
ISRC string `json:"isrc"`
SongUrls map[string]json.RawMessage `json:"songUrls"`
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)
+48 -15
View File
@@ -51,6 +51,7 @@ type TidalTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
ISRC string `json:"isrc"`
Copyright string `json:"copyright"`
AudioQuality string `json:"audioQuality"`
TrackNumber int `json:"trackNumber"`
VolumeNumber int `json:"volumeNumber"`
@@ -135,6 +136,7 @@ type tidalPublicAlbum struct {
Type string `json:"type"`
Cover string `json:"cover"`
ReleaseDate string `json:"releaseDate"`
Copyright string `json:"copyright"`
URL string `json:"url"`
NumberOfTracks int `json:"numberOfTracks"`
Explicit bool `json:"explicit"`
@@ -306,6 +308,29 @@ func tidalTrackArtistsDisplay(track *TidalTrack) string {
return strings.TrimSpace(track.Artist.Name)
}
func tidalTrackAlbumArtistDisplay(track *TidalTrack) string {
if track == nil {
return ""
}
if len(track.Artists) > 0 {
names := make([]string, 0, len(track.Artists))
for _, artist := range track.Artists {
if strings.ToUpper(strings.TrimSpace(artist.Type)) != "MAIN" {
continue
}
if trimmed := strings.TrimSpace(artist.Name); trimmed != "" {
names = append(names, trimmed)
}
}
if len(names) > 0 {
return strings.Join(names, ", ")
}
}
return strings.TrimSpace(track.Artist.Name)
}
func tidalAlbumArtistsDisplay(album *tidalPublicAlbum) string {
if album == nil {
return ""
@@ -354,7 +379,7 @@ func tidalTrackToTrackMetadata(track *TidalTrack) TrackMetadata {
Artists: tidalTrackArtistsDisplay(track),
Name: strings.TrimSpace(track.Title),
AlbumName: strings.TrimSpace(track.Album.Title),
AlbumArtist: strings.TrimSpace(track.Artist.Name),
AlbumArtist: tidalTrackAlbumArtistDisplay(track),
DurationMS: track.Duration * 1000,
Images: tidalImageURL(track.Album.Cover, "1280x1280"),
ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
@@ -377,7 +402,7 @@ func tidalTrackToAlbumTrackMetadata(track *TidalTrack) AlbumTrackMetadata {
Artists: tidalTrackArtistsDisplay(track),
Name: strings.TrimSpace(track.Title),
AlbumName: strings.TrimSpace(track.Album.Title),
AlbumArtist: strings.TrimSpace(track.Artist.Name),
AlbumArtist: tidalTrackAlbumArtistDisplay(track),
DurationMS: track.Duration * 1000,
Images: tidalImageURL(track.Album.Cover, "1280x1280"),
ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
@@ -407,6 +432,7 @@ func tidalAlbumToAlbumInfo(album *tidalPublicAlbum) AlbumInfoMetadata {
Artists: tidalAlbumArtistsDisplay(album),
ArtistId: artistID,
Images: tidalImageURL(album.Cover, "1280x1280"),
Copyright: strings.TrimSpace(album.Copyright),
}
}
@@ -688,6 +714,10 @@ func findTidalArtistPageModule(page *tidalPublicArtistPage, moduleType string) *
func (t *TidalDownloader) GetAvailableAPIs() []string {
return []string{
"https://eu-central.monochrome.tf",
"https://us-west.monochrome.tf",
"https://api.monochrome.tf",
"https://monochrome-api.samidy.com",
"https://tidal-api.binimum.org",
"https://tidal.kinoplus.online",
"https://triton.squid.wtf",
@@ -1012,6 +1042,7 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
}
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
totalDiscs := 0
for _, item := range itemsModule.PagedList.Items {
track := item.Item
track.Album.ID = headerModule.Album.ID
@@ -1019,8 +1050,14 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
track.Album.Cover = headerModule.Album.Cover
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
track.Album.URL = headerModule.Album.URL
if track.VolumeNumber > totalDiscs {
totalDiscs = track.VolumeNumber
}
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
}
for i := range tracks {
tracks[i].TotalDiscs = totalDiscs
}
return &AlbumResponsePayload{
AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album),
@@ -1729,6 +1766,7 @@ type TidalDownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
Copyright string
LyricsLRC string // LRC content for embedding in converted files
}
@@ -2042,18 +2080,6 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
}
}
if !gotTidalID && req.ISRC != "" {
GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
if directErr == nil && directTrack != nil && directTrack.ID > 0 {
trackID = directTrack.ID
gotTidalID = true
GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
} else if directErr != nil {
GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
}
}
if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
@@ -2349,6 +2375,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if actualDiscNumber == 0 {
actualDiscNumber = track.VolumeNumber
}
copyright := strings.TrimSpace(req.Copyright)
if copyright == "" {
copyright = strings.TrimSpace(track.Copyright)
}
metadata := Metadata{
Title: req.TrackName,
@@ -2360,10 +2390,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNumber,
TotalDiscs: req.TotalDiscs,
ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Copyright: copyright,
Composer: req.Composer,
}
var coverData []byte
@@ -2473,6 +2505,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
Copyright: copyright,
LyricsLRC: lyricsLRC,
}, nil
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 71 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+60 -10
View File
@@ -22,6 +22,9 @@ import Gobackend // Import Go framework
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
GobackendSetAppVersion(version)
}
let controller = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(
@@ -66,9 +69,59 @@ import Gobackend // Import Go framework
)
GeneratedPluginRegistrant.register(with: self)
if let url = launchOptions?[.url] as? URL {
_ = handleExtensionOAuthRedirect(url: url)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
/// PKCE OAuth return URL: spotiflac://callback?code=...&state=<extension_id>
@discardableResult
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
let host = (url.host ?? "").lowercased()
let path = url.path.lowercased()
let ok =
host == "callback" || host == "spotify-callback" || path.contains("callback")
guard ok else { return false }
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return false
}
let q = components.queryItems ?? []
let code =
q.first { $0.name == "code" }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ?? ""
let state =
q.first { $0.name == "state" }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ?? ""
if code.isEmpty { return false }
if state.isEmpty {
NSLog("SpotiFLAC: Extension OAuth redirect missing state (extension id)")
return false
}
streamQueue.async {
var err: NSError?
GobackendSetExtensionAuthCodeByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
if let err = err {
NSLog(
"SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)")
}
}
return true
}
override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if handleExtensionOAuthRedirect(url: url) {
return true
}
return super.application(app, open: url, options: options)
}
deinit {
stopDownloadProgressStream()
stopLibraryScanProgressStream()
@@ -371,16 +424,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "searchDeezerAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let filter = args["filter"] as? String ?? ""
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error }
return response
case "searchTidalAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
@@ -607,6 +650,13 @@ import Gobackend // Import Go framework
let response = GobackendGetProviderPriorityJSON(&error)
if let error = error { throw error }
return response
case "setDownloadFallbackExtensionIds":
let args = call.arguments as! [String: Any]
let extensionIdsJson = args["extension_ids"] as? String ?? ""
GobackendSetExtensionFallbackProviderIDsJSON(extensionIdsJson, &error)
if let error = error { throw error }
return nil
case "setMetadataProviderPriority":
let args = call.arguments as! [String: Any]
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '4.2.0';
static const String buildNumber = '121';
static const String version = '4.3.1';
static const String buildNumber = '126';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
+53
View File
@@ -17,6 +17,7 @@ import 'app_localizations_nl.dart';
import 'app_localizations_pt.dart';
import 'app_localizations_ru.dart';
import 'app_localizations_tr.dart';
import 'app_localizations_uk.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
@@ -119,6 +120,7 @@ abstract class AppLocalizations {
Locale('pt', 'PT'),
Locale('ru'),
Locale('tr'),
Locale('uk'),
Locale('zh'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
@@ -352,6 +354,18 @@ abstract class AppLocalizations {
/// **'Using extension: {extensionName}'**
String optionsUsingExtension(String extensionName);
/// Title for the preferred default search tab setting
///
/// In en, this message translates to:
/// **'Default Search Tab'**
String get optionsDefaultSearchTab;
/// Subtitle for the preferred default search tab setting
///
/// In en, this message translates to:
/// **'Choose which tab opens first for new search results.'**
String get optionsDefaultSearchTabSubtitle;
/// Hint to switch back to built-in providers
///
/// In en, this message translates to:
@@ -718,6 +732,12 @@ abstract class AppLocalizations {
/// **'PC source code'**
String get aboutPCSource;
/// Link to Keep Android Open campaign website
///
/// In en, this message translates to:
/// **'Keep Android Open'**
String get aboutKeepAndroidOpen;
/// Link to report bugs
///
/// In en, this message translates to:
@@ -1738,6 +1758,24 @@ abstract class AppLocalizations {
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
String get providerPriorityInfo;
/// Section title for choosing which download extensions can be used as fallback providers
///
/// In en, this message translates to:
/// **'Extension Fallback'**
String get providerPriorityFallbackExtensionsTitle;
/// Section description for extension fallback selection
///
/// In en, this message translates to:
/// **'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.'**
String get providerPriorityFallbackExtensionsDescription;
/// Hint below the extension fallback selection list
///
/// In en, this message translates to:
/// **'Only enabled extensions with download-provider capability are listed here.'**
String get providerPriorityFallbackExtensionsHint;
/// Label for built-in providers (Tidal/Qobuz)
///
/// In en, this message translates to:
@@ -2644,6 +2682,18 @@ abstract class AppLocalizations {
/// **'Set download service order'**
String get extensionsDownloadPrioritySubtitle;
/// Setting and page title for choosing which download extensions can be used during fallback
///
/// In en, this message translates to:
/// **'Fallback Extensions'**
String get extensionsFallbackTitle;
/// Subtitle for download fallback extensions menu
///
/// In en, this message translates to:
/// **'Choose which installed download extensions can be used as fallback'**
String get extensionsFallbackSubtitle;
/// Empty state - no download providers
///
/// In en, this message translates to:
@@ -5809,6 +5859,7 @@ class _AppLocalizationsDelegate
'pt',
'ru',
'tr',
'uk',
'zh',
].contains(locale.languageCode);
@@ -5873,6 +5924,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsRu();
case 'tr':
return AppLocalizationsTr();
case 'uk':
return AppLocalizationsUk();
case 'zh':
return AppLocalizationsZh();
}
+167 -136
View File
@@ -21,13 +21,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get navSettings => 'Einstellungen';
@override
String get navStore => 'Store';
String get navStore => 'Repo';
@override
String get homeTitle => 'Startseite';
@override
String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen';
String get homeSubtitle => 'Unterstützte URL einfügen oder nach Namen suchen';
@override
String get homeSupports =>
@@ -129,6 +129,13 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Erweiterung verwenden: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tippe auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln';
@@ -177,21 +184,21 @@ class AppLocalizationsDe extends AppLocalizations {
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
String get optionsArtistTagMode => 'Künstler Tag-Modus';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
'Wähle aus, wie mehrere Künstler in eingebetteten Tags geschrieben sind.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
String get optionsArtistTagModeJoined => 'Einzelne beigefügte Werte';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
'Einen Künstler wert wie \"Artist A, Artist B\" für maximale Spieler-Kompatibilität schreiben.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
String get optionsArtistTagModeSplitVorbis => 'Tags für FLAC/Opus aufteilen';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
@@ -213,11 +220,11 @@ class AppLocalizationsDe extends AppLocalizations {
'Parallele Downloads können Ratenlimitierung auslösen';
@override
String get optionsExtensionStore => 'Erweiterungs-Store';
String get optionsExtensionStore => 'Erweiterungs-Repo';
@override
String get optionsExtensionStoreSubtitle =>
'Store-Tab in Navigation anzeigen';
'Repo-Tab in der Navigation anzeigen';
@override
String get optionsCheckUpdates => 'Nach Updates suchen';
@@ -296,7 +303,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get extensionsUninstall => 'Deinstallieren';
@override
String get storeTitle => 'Erweiterungs-Store';
String get storeTitle => 'Erweiterungs-Repo';
@override
String get storeSearch => 'Erweiterungen suchen...';
@@ -341,6 +348,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutPCSource => 'PC Quellcode';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Problem melden';
@@ -576,7 +586,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get dialogImport => 'Importieren';
@override
String get dialogDownload => 'Download';
String get dialogDownload => 'Herunterladen';
@override
String get dialogDiscard => 'Verwerfen';
@@ -809,37 +819,37 @@ class AppLocalizationsDe extends AppLocalizations {
String get searchAlbums => 'Alben';
@override
String get searchPlaylists => 'Playlisten';
String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
String get searchSortTitle => 'Ergebnisse sortieren';
@override
String get searchSortDefault => 'Default';
String get searchSortDefault => 'Standard';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
String get searchSortTitleAZ => 'Titel (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
String get searchSortTitleZA => 'Titel (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
String get searchSortArtistAZ => 'Künstler (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
String get searchSortArtistZA => 'Künstler (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
String get searchSortDurationShort => 'Dauer (kürzeste)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
String get searchSortDurationLong => 'Dauer (längste)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
String get searchSortDateOldest => 'Veröffentlichungsdatum (älteste)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
String get searchSortDateNewest => 'Veröffentlichungsdatum (Neueste)';
@override
String get tooltipPlay => 'Abspielen';
@@ -940,6 +950,17 @@ class AppLocalizationsDe extends AppLocalizations {
String get providerPriorityInfo =>
'Wenn kein Titel bei dem ersten Anbieter nicht verfügbar ist, wird die App automatisch den nächsten versuchen.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Integriert';
@@ -1294,36 +1315,36 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeClearFilters => 'Filter entfernen';
@override
String get storeAddRepoTitle => 'Add Extension Repository';
String get storeAddRepoTitle => 'Erweiterungs-Repository hinzufügen';
@override
String get storeAddRepoDescription =>
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
'Gib eine GitHub Repository-URL ein, die eine Registry.json Datei enthält, um Erweiterungen zu durchsuchen und zu installieren.';
@override
String get storeRepoUrlLabel => 'Repository URL';
String get storeRepoUrlLabel => 'Repository-URL';
@override
String get storeRepoUrlHint => 'https://github.com/user/repo';
@override
String get storeRepoUrlHelper =>
'e.g. https://github.com/user/extensions-repo';
'z.B. https://github.com/user/extensions-repo';
@override
String get storeAddRepoButton => 'Add Repository';
String get storeAddRepoButton => 'Repository hinzufügen';
@override
String get storeChangeRepoTooltip => 'Change repository';
String get storeChangeRepoTooltip => 'Repository ändern';
@override
String get storeRepoDialogTitle => 'Extension Repository';
String get storeRepoDialogTitle => 'Erweiterungs-Repository';
@override
String get storeRepoDialogCurrent => 'Current repository:';
String get storeRepoDialogCurrent => 'Aktuelles Repository:';
@override
String get storeNewRepoUrlLabel => 'New Repository URL';
String get storeNewRepoUrlLabel => 'Neue Repository-URL';
@override
String get storeLoadError => 'Failed to load repository';
@@ -1335,7 +1356,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Standard (Deezer/Spotify)';
String get extensionDefaultProvider => 'Standard (Deezer)';
@override
String get extensionDefaultProviderSubtitle => 'Eingebaute Suche verwenden';
@@ -1438,6 +1459,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle =>
'Download-Service-Reihenfolge festlegen';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'Keine Erweiterungen mit Download-Provider';
@@ -1489,36 +1517,36 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
String get downloadLossy320 => 'Verlustbehaftet 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
String get downloadLossyFormat => 'Verlustbehaftetes Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
'Wähle das Ausgabeformat für Tidal 320kbps verlustbehaftete Downloads. Der ursprüngliche AAC Stream wird in das ausgewählte Format konvertiert.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
String get downloadLossyMp3Subtitle =>
'Beste Kompatibilität, ~10MB pro Titel';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
String get downloadLossyOpus256Subtitle => 'Beste Qualität, ~8MB pro Titel';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
String get downloadLossyOpus128Subtitle => 'Kleinste Größe, ~4MB pro Track';
@override
String get qualityNote =>
@@ -1828,23 +1856,23 @@ class AppLocalizationsDe extends AppLocalizations {
'Bei der Suche nach vorhandenen Titeln anzeigen';
@override
String get libraryAutoScan => 'Auto Scan';
String get libraryAutoScan => 'Auto-Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
String get libraryAutoScanOff => 'Aus';
@override
String get libraryAutoScanOnOpen => 'Every app open';
String get libraryAutoScanOnOpen => 'Bei jeder App Öffnung';
@override
String get libraryAutoScanDaily => 'Daily';
String get libraryAutoScanDaily => 'Täglich';
@override
String get libraryAutoScanWeekly => 'Weekly';
String get libraryAutoScanWeekly => 'Wöchentlich';
@override
String get libraryActions => 'Aktionen';
@@ -1901,8 +1929,8 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
other: '$count Datein',
one: '1 Datei',
);
return '$_temp0';
}
@@ -1919,7 +1947,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get libraryScanning => 'Scannen...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
String get libraryScanFinalizing => 'Bibliothek wird aktualisiert...';
@override
String libraryScanProgress(String progress, int total) {
@@ -1990,22 +2018,23 @@ class AppLocalizationsDe extends AppLocalizations {
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
String get libraryFilterMetadata => 'Metadaten';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
String get libraryFilterMetadataComplete => 'Komplette Metadaten';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
String get libraryFilterMetadataMissingAny => 'Metadaten fehlen';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
String get libraryFilterMetadataMissingYear => 'Jahr fehlt';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
String get libraryFilterMetadataMissingGenre => 'Genre fehlt';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
String get libraryFilterMetadataMissingAlbumArtist =>
'Fehlender Album-Künstler';
@override
String get libraryFilterSort => 'Sortieren';
@@ -2037,7 +2066,7 @@ class AppLocalizationsDe extends AppLocalizations {
count,
locale: localeName,
other: 'vor $count Minuten',
one: 'vor $count Minute',
one: 'vor 1 Minute',
);
return '$_temp0';
}
@@ -2048,7 +2077,7 @@ class AppLocalizationsDe extends AppLocalizations {
count,
locale: localeName,
other: 'vor $count Stunden',
one: 'vor $count Stunde',
one: 'vor 1 Stunde',
);
return '$_temp0';
}
@@ -2114,7 +2143,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Im Store Tab findest du nützliche Erweiterungen';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2368,11 +2397,11 @@ class AppLocalizationsDe extends AppLocalizations {
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
@override
String get queueFlacAction => 'Queue FLAC';
String get queueFlacAction => 'Warteschlange FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
return 'Suche Online-Matches für ausgewählte Titel und Playlists für FLAC-Downloads.\n\nVorhandene Dateien werden weder geändert noch gelöscht.\n\nNur eindeutige Treffer werden automatisch zur Warteschlange hinzugefügt.\n\n$count ausgewählt';
}
@override
@@ -2398,7 +2427,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackConvertFormat => 'Format konvertieren';
@override
String get trackConvertFormatSubtitle => 'In MP3 oder Opus konvertieren';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Audio konvertieren';
@@ -2426,7 +2456,7 @@ class AppLocalizationsDe extends AppLocalizations {
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
return 'Konvertieren von $sourceFormat in $targetFormat? (kein Qualitätsverlust)\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
}
@override
@@ -2506,7 +2536,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get collectionLoved => 'Lieblingssongs';
@override
String get collectionPlaylists => 'Playlisten';
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@@ -2693,10 +2723,10 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
other: 'Titel',
one: 'Titel',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
return 'Konvertiere $count $_temp0 in $format? (kein Qualitätsverlust)\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
}
@override
@@ -2723,24 +2753,24 @@ class AppLocalizationsDe extends AppLocalizations {
'Künstler-Ordner nur für Titel-Künstler';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
String get lyricsProvidersTitle => 'Lyrics-Anbieter';
@override
String get lyricsProvidersDescription =>
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
'Lyrics aktivieren, deaktivieren und neu ordnen. Anbieter werden von oben nach unten ausprobiert, bis Lyrics gefunden werden.';
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
'Erweiterungsanbieter werden immer vor eingebauten ausgeführt. Mindestens ein Anbieter muss aktiviert bleiben.';
@override
String lyricsProvidersEnabledSection(int count) {
return 'Enabled ($count)';
return '($count) aktiviert';
}
@override
String lyricsProvidersDisabledSection(int count) {
return 'Disabled ($count)';
return '($count) deaktiviert';
}
@override
@@ -2759,52 +2789,53 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get lyricsProviderNeteaseDesc =>
'NetEase Cloud Music (good for Asian songs)';
'NetEase Cloud Music (gut für asiatische Lieder)';
@override
String get lyricsProviderMusixmatchDesc =>
'Largest lyrics database (multi-language)';
'Größte Lyrics-Datenbank (mehrsprachig)';
@override
String get lyricsProviderAppleMusicDesc =>
'Word-by-word synced lyrics (via proxy)';
'Wort-für-Wort-synchronisierte Lyrics (via Proxy)';
@override
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
'QQ Music (gut für chinesische Lieder, via Proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
String get lyricsProviderExtensionDesc => 'Erweiterungsanbieter';
@override
String get safMigrationTitle => 'Storage Update Required';
String get safMigrationTitle => 'Speicheraktualisierung erforderlich';
@override
String get safMigrationMessage1 =>
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
'SpotiFLAC verwendet jetzt Android Storage Access Framework (SAF) beim Herunterladen. Dies behebt Fehler bei Android 10+.';
@override
String get safMigrationMessage2 =>
'Please select your download folder again to switch to the new storage system.';
'Bitte wähle dein Download-Ordner erneut aus, um zum neuen System zu wechseln.';
@override
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
String get settingsDonate => 'Unterstützen';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
String get settingsDonateSubtitle =>
'Unterstütze die SpotiFLAC-Mobile Entwickler';
@override
String get tooltipLoveAll => 'Love All';
String get tooltipLoveAll => 'Alle lieben';
@override
String get tooltipAddToPlaylist => 'Add to Playlist';
String get tooltipAddToPlaylist => 'Zur Wiedergabeliste hinzufügen';
@override
String snackbarRemovedTracksFromLoved(int count) {
return 'Removed $count tracks from Loved';
return '$count Titel von geliebt entfernt';
}
@override
@@ -2813,7 +2844,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get dialogDownloadAllTitle => 'Download All';
String get dialogDownloadAllTitle => 'Alle Herunterladen';
@override
String dialogDownloadAllMessage(int count) {
@@ -2824,7 +2855,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@override
String get homeGoToAlbum => 'Go to Album';
String get homeGoToAlbum => 'Zum Album gehen';
@override
String get homeAlbumInfoUnavailable => 'Album info not available';
@@ -2843,7 +2874,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String snackbarError(String error) {
return 'Error: $error';
return 'Fehler: $error';
}
@override
@@ -2863,7 +2894,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
@override
String get storageModeSaf => 'SAF folder';
String get storageModeSaf => 'SAF-Ordner';
@override
String get storageModeSafSubtitle =>
@@ -2874,7 +2905,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Customize how your files are named.';
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
String get downloadFilenameInsertTag => 'Tippe, um Tag einzufügen:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
@@ -2902,10 +2933,10 @@ class AppLocalizationsDe extends AppLocalizations {
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
String get downloadSongLinkRegion => 'SongLink-Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
String get downloadNetworkCompatibilityMode => 'Netzwerkkompatibilitätsmodus';
@override
String get downloadNetworkCompatibilityModeEnabled =>
@@ -2948,7 +2979,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Append romanized lyrics when available';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
String get downloadNeteaseIncludeRomanizationDisabled => 'Deaktiviert';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
@@ -2980,10 +3011,10 @@ class AppLocalizationsDe extends AppLocalizations {
'Keep full Album Artist metadata value';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
String get downloadProvidersNoneEnabled => 'Keine aktiviert';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
String get downloadMusixmatchLanguageCode => 'Sprach-Code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
@@ -2996,7 +3027,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
String get downloadNetworkAnySubtitle => 'WLAN + Mobile Daten';
@override
String get downloadNetworkWifiOnlySubtitle =>
@@ -3010,23 +3041,23 @@ class AppLocalizationsDe extends AppLocalizations {
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@override
String get cacheRefresh => 'Refresh';
String get cacheRefresh => 'Aktualisieren';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
other: 'Titel',
one: 'Titel',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
other: 'Playlists',
one: 'Playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
return 'Lade $trackCount $_temp0 von $playlistCount $_temp1?';
}
@override
@@ -3066,7 +3097,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
String get editMetadataAutoFillFetch => 'Abrufen & Ausfüllen';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@@ -3091,25 +3122,25 @@ class AppLocalizationsDe extends AppLocalizations {
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
String get editMetadataFieldTitle => 'Titel';
@override
String get editMetadataFieldArtist => 'Artist';
String get editMetadataFieldArtist => 'Künstler';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
String get editMetadataFieldAlbumArtist => 'Album Künstler';
@override
String get editMetadataFieldDate => 'Date';
String get editMetadataFieldDate => 'Datum';
@override
String get editMetadataFieldTrackNum => 'Track #';
String get editMetadataFieldTrackNum => 'Titel #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
String get editMetadataFieldDiscNum => 'Disk #';
@override
String get editMetadataFieldGenre => 'Genre';
@@ -3121,16 +3152,16 @@ class AppLocalizationsDe extends AppLocalizations {
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
String get editMetadataFieldCopyright => 'Urheberrecht';
@override
String get editMetadataFieldCover => 'Cover Art';
String get editMetadataFieldCover => 'Cover-Art';
@override
String get editMetadataSelectAll => 'All';
String get editMetadataSelectAll => 'Alle';
@override
String get editMetadataSelectEmpty => 'Empty only';
String get editMetadataSelectEmpty => 'Nur leer';
@override
String queueDownloadingCount(int count) {
@@ -3138,10 +3169,10 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get queueDownloadedHeader => 'Downloaded';
String get queueDownloadedHeader => 'Heruntergeladen';
@override
String get queueFilteringIndicator => 'Filtering...';
String get queueFilteringIndicator => 'Filtere...';
@override
String queueTrackCount(int count) {
@@ -3166,7 +3197,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get queueEmptyAlbums => 'No album downloads';
String get queueEmptyAlbums => 'Keine Album-Downloads';
@override
String get queueEmptyAlbumsSubtitle =>
@@ -3192,7 +3223,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
String get selectionSelectPlaylistsToDelete => 'Playlist zum Löschen wählen';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@@ -3202,37 +3233,37 @@ class AppLocalizationsDe extends AppLocalizations {
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
String get audioAnalysisAnalyzing => 'Audio wird analysiert...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
String get audioAnalysisBitDepth => 'Bit-Tiefe';
@override
String get audioAnalysisChannels => 'Channels';
String get audioAnalysisChannels => 'Kanäle';
@override
String get audioAnalysisDuration => 'Duration';
String get audioAnalysisDuration => 'Länge';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
String get audioAnalysisFileSize => 'Größe';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
String get audioAnalysisDynamicRange => 'Dynamischer Bereich';
@override
String get audioAnalysisPeak => 'Peak';
String get audioAnalysisPeak => 'Maximum';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
String get audioAnalysisSamples => 'Proben';
@override
String extensionsSearchWith(String providerName) {
@@ -3240,7 +3271,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
String get extensionsHomeFeedProvider => 'Home Feed Anbieter';
@override
String get extensionsHomeFeedDescription =>
@@ -3268,7 +3299,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
String get cancelDownloadTitle => 'Download abbrechen?';
@override
String cancelDownloadContent(String trackName) {
@@ -3276,7 +3307,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get cancelDownloadKeep => 'Keep';
String get cancelDownloadKeep => 'Behalten';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@@ -3291,22 +3322,22 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get errorLoadAlbum => 'Failed to load album';
String get errorLoadAlbum => 'Fehler beim Laden des Albums';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
String get errorLoadPlaylist => 'Fehler beim Laden der Playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
String get errorLoadArtist => 'Fehler beim Laden des Interpreten';
@override
String get notifChannelDownloadName => 'Download Progress';
String get notifChannelDownloadName => 'Download Fortschritt';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
String get notifChannelLibraryScanName => 'Bibliotheksscan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@@ -3330,7 +3361,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
String get notifAlreadyInLibrary => 'Bereits in der Bibliothek';
@override
String notifDownloadCompleteCount(int completed, int total) {
@@ -3338,7 +3369,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get notifDownloadComplete => 'Download Complete';
String get notifDownloadComplete => 'Download abgeschlossen';
@override
String notifDownloadsFinished(int completed, int failed) {
@@ -3380,12 +3411,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
return '$count ausgeschlossen';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
return '$count Fehler';
}
@override
@@ -3408,7 +3439,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get notifUpdateReady => 'Update Ready';
String get notifUpdateReady => 'Update bereit';
@override
String notifUpdateReadyBody(String version) {
@@ -3416,7 +3447,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get notifUpdateFailed => 'Update Failed';
String get notifUpdateFailed => 'Update fehlgeschlagen';
@override
String get notifUpdateFailedBody =>
+28
View File
@@ -127,6 +127,13 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -334,6 +341,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -926,6 +936,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1415,6 +1436,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
File diff suppressed because it is too large Load Diff
+63 -33
View File
@@ -21,13 +21,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get navSettings => 'Paramètres';
@override
String get navStore => 'Magasin';
String get navStore => 'Repo';
@override
String get homeTitle => 'Accueil';
@override
String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom';
String get homeSubtitle => 'Paste a supported URL or search by name';
@override
String get homeSupports => 'Supports: Piste, Album, Playlist, Artiste URLs';
@@ -128,6 +128,13 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Utilisation de l\'extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
@@ -211,10 +218,10 @@ class AppLocalizationsFr extends AppLocalizations {
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Store';
String get optionsExtensionStore => 'Extension Repo';
@override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
@override
String get optionsCheckUpdates => 'Check for Updates';
@@ -275,7 +282,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get extensionsTitle => 'Extensions';
@override
String get extensionsDisabled => 'Disabled';
String get extensionsDisabled => 'Désactivée';
@override
String extensionsVersion(String version) {
@@ -284,38 +291,38 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String extensionsAuthor(String author) {
return 'by $author';
return 'par $author';
}
@override
String get extensionsUninstall => 'Désinstaller';
@override
String get storeTitle => 'Magasin d\'extension';
String get storeTitle => 'Extension Repo';
@override
String get storeSearch => 'Recherche d\'extensions...';
@override
String get storeInstall => 'Install';
String get storeInstall => 'Installer';
@override
String get storeInstalled => 'Installed';
String get storeInstalled => 'Installé';
@override
String get storeUpdate => 'Update';
String get storeUpdate => 'Mettre à jour';
@override
String get aboutTitle => 'About';
String get aboutTitle => 'À propos de';
@override
String get aboutContributors => 'Contributors';
String get aboutContributors => 'Contributeurs';
@override
String get aboutMobileDeveloper => 'Mobile version developer';
String get aboutMobileDeveloper => 'Développeur de la version mobile';
@override
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
String get aboutOriginalCreator => 'Créateur de SpotiFLAC original';
@override
String get aboutLogoArtist =>
@@ -336,6 +343,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -352,7 +362,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
String get aboutTelegramChannelSubtitle => 'Annonces et mises à jour';
@override
String get aboutTelegramChat => 'Telegram Community';
@@ -510,10 +520,11 @@ class AppLocalizationsFr extends AppLocalizations {
'SpotiFLAC needs storage permission to save your downloaded music files.';
@override
String get setupNotificationGranted => 'Notification Permission Granted!';
String get setupNotificationGranted =>
'Autorisation de notifications accordée!';
@override
String get setupNotificationEnable => 'Enable Notifications';
String get setupNotificationEnable => 'Activer les notifications';
@override
String get setupFolderChoose => 'Choisissez le dossier pour télécharger';
@@ -523,39 +534,39 @@ class AppLocalizationsFr extends AppLocalizations {
'Sélectionnez un dossier dans lequel votre musique téléchargée sera enregistrée.';
@override
String get setupSelectFolder => 'Select Folder';
String get setupSelectFolder => 'Sélectionner un dossier';
@override
String get setupEnableNotifications => 'Enable Notifications';
String get setupEnableNotifications => 'Activer les notifications';
@override
String get setupNotificationBackgroundDescription =>
'Get notified about download progress and completion. This helps you track downloads when the app is in background.';
@override
String get setupSkipForNow => 'Skip for now';
String get setupSkipForNow => 'Ignorer pour le moment';
@override
String get setupNext => 'Next';
String get setupNext => 'Suivant';
@override
String get setupGetStarted => 'Get Started';
String get setupGetStarted => 'Démarrer';
@override
String get setupAllowAccessToManageFiles =>
'Please enable \"Allow access to manage all files\" in the next screen.';
'Veuillez activer \"Autoriser l\'accès à tous les fichiers\" sur l\'écran suivant.';
@override
String get dialogCancel => 'Cancel';
String get dialogCancel => 'Annuler';
@override
String get dialogSave => 'Save';
String get dialogSave => 'Sauvegarder';
@override
String get dialogDelete => 'Delete';
String get dialogDelete => 'Supprimer';
@override
String get dialogRetry => 'Retry';
String get dialogRetry => 'Réessayer';
@override
String get dialogClear => 'Clear';
@@ -567,7 +578,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
String get dialogDownload => 'Télécharger';
@override
String get dialogDiscard => 'Discard';
@@ -576,10 +587,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get dialogRemove => 'Remove';
@override
String get dialogUninstall => 'Uninstall';
String get dialogUninstall => 'Désinstaller';
@override
String get dialogDiscardChanges => 'Discard Changes?';
String get dialogDiscardChanges => 'Ignorer les modifications ?';
@override
String get dialogUnsavedChanges =>
@@ -928,6 +939,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1317,7 +1339,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
String get extensionDefaultProvider => 'Default (Deezer)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
@@ -1417,6 +1439,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
@@ -2088,7 +2117,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2369,7 +2398,8 @@ class AppLocalizationsFr extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
+37 -8
View File
@@ -21,13 +21,13 @@ class AppLocalizationsHi extends AppLocalizations {
String get navSettings => 'विकल्प';
@override
String get navStore => 'Store';
String get navStore => 'Repo';
@override
String get homeTitle => 'Home';
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
String get homeSubtitle => 'Paste a supported URL or search by name';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -127,6 +127,13 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -209,10 +216,10 @@ class AppLocalizationsHi extends AppLocalizations {
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Store';
String get optionsExtensionStore => 'Extension Repo';
@override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
@override
String get optionsCheckUpdates => 'Check for Updates';
@@ -289,7 +296,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get extensionsUninstall => 'Uninstall';
@override
String get storeTitle => 'Extension Store';
String get storeTitle => 'Extension Repo';
@override
String get storeSearch => 'Search extensions...';
@@ -334,6 +341,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -926,6 +936,17 @@ class AppLocalizationsHi extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1315,7 +1336,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
String get extensionDefaultProvider => 'Default (Deezer)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
@@ -1415,6 +1436,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
@@ -2086,7 +2114,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2367,7 +2395,8 @@ class AppLocalizationsHi extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
+50 -23
View File
@@ -27,8 +27,7 @@ class AppLocalizationsId extends AppLocalizations {
String get homeTitle => 'Beranda';
@override
String get homeSubtitle =>
'Tempel URL yang didukung atau cari berdasarkan nama';
String get homeSubtitle => 'Paste a supported URL or search by name';
@override
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
@@ -129,6 +128,13 @@ class AppLocalizationsId extends AppLocalizations {
return 'Menggunakan ekstensi: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
@@ -213,10 +219,10 @@ class AppLocalizationsId extends AppLocalizations {
'Unduhan paralel dapat memicu pembatasan rate';
@override
String get optionsExtensionStore => 'Repo Ekstensi';
String get optionsExtensionStore => 'Extension Repo';
@override
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Repo di navigasi';
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
@override
String get optionsCheckUpdates => 'Periksa Pembaruan';
@@ -292,7 +298,7 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsUninstall => 'Copot';
@override
String get storeTitle => 'Repo Ekstensi';
String get storeTitle => 'Extension Repo';
@override
String get storeSearch => 'Cari ekstensi...';
@@ -337,6 +343,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutPCSource => 'Kode sumber PC';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Laporkan masalah';
@@ -735,15 +744,15 @@ class AppLocalizationsId extends AppLocalizations {
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
@override
String get errorUrlNotRecognized => 'Link tidak dikenali';
String get errorUrlNotRecognized => 'Tautan tidak dikenali';
@override
String get errorUrlNotRecognizedMessage =>
'Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.';
'Tautan ini tidak didukung. Pastikan URL sudah benar dan ekstensi yang kompatibel telah terpasang.';
@override
String get errorUrlFetchFailed =>
'Gagal memuat konten dari link ini. Silakan coba lagi.';
'Konten dari tautan ini gagal dimuat. Silakan coba lagi.';
@override
String errorMissingExtensionSource(String item) {
@@ -930,6 +939,17 @@ class AppLocalizationsId extends AppLocalizations {
String get providerPriorityInfo =>
'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Bawaan';
@@ -1313,7 +1333,7 @@ class AppLocalizationsId extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Gagal memuat repo';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1322,7 +1342,7 @@ class AppLocalizationsId extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Bawaan (Deezer/Spotify)';
String get extensionDefaultProvider => 'Default (Deezer)';
@override
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
@@ -1423,6 +1443,13 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle =>
'Atur urutan layanan unduhan';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'Tidak ada ekstensi dengan provider unduhan';
@@ -2096,7 +2123,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Buka tab Repo untuk menemukan ekstensi yang berguna';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2347,25 +2374,25 @@ class AppLocalizationsId extends AppLocalizations {
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Antrekan FLAC';
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih';
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Mencari kecocokan FLAC... ($current/$total)';
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini';
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount';
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
@@ -2378,7 +2405,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Konversi ke MP3, Opus, ALAC, atau FLAC';
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2406,12 +2433,12 @@ class AppLocalizationsId extends AppLocalizations {
String sourceFormat,
String targetFormat,
) {
return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Konversi lossless — tanpa kehilangan kualitas';
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2865,19 +2892,19 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadCreatePlaylistSourceFolder =>
'Buat folder sumber playlist';
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.';
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Unduhan dari playlist hanya memakai struktur folder normal.';
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.';
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
+37 -8
View File
@@ -21,13 +21,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get navSettings => '設定';
@override
String get navStore => 'ストア';
String get navStore => 'Repo';
@override
String get homeTitle => 'ホーム';
@override
String get homeSubtitle => 'Spotify のリンクを貼り付けるか、名前で検索します';
String get homeSubtitle => 'Paste a supported URL or search by name';
@override
String get homeSupports => 'サポート: トラック、アルバム、プレイリスト、アーティスト、URL';
@@ -127,6 +127,13 @@ class AppLocalizationsJa extends AppLocalizations {
return '拡張の使用: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -207,10 +214,10 @@ class AppLocalizationsJa extends AppLocalizations {
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => '拡張ストア';
String get optionsExtensionStore => 'Extension Repo';
@override
String get optionsExtensionStoreSubtitle => 'ナビゲーションにストアタブを表示';
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
@override
String get optionsCheckUpdates => '更新を確認';
@@ -286,7 +293,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get extensionsUninstall => 'アンインストール';
@override
String get storeTitle => '拡張ストア';
String get storeTitle => 'Extension Repo';
@override
String get storeSearch => '拡張を検索...';
@@ -330,6 +337,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutPCSource => 'PC 版のソースコード';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => '問題を報告する';
@@ -920,6 +930,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => '内蔵';
@@ -1309,7 +1330,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'デフォルト (Deezer/Spotify)';
String get extensionDefaultProvider => 'Default (Deezer)';
@override
String get extensionDefaultProviderSubtitle => '内蔵の検索を使用する';
@@ -1409,6 +1430,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません';
@@ -2073,7 +2101,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2354,7 +2382,8 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackConvertFormat => '変換の形式';
@override
String get trackConvertFormatSubtitle => 'MP3 または Opus に変換';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'オーディオを変換';
+37 -8
View File
@@ -21,13 +21,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get navSettings => 'Settings';
@override
String get navStore => 'Store';
String get navStore => 'Repo';
@override
String get homeTitle => 'Home';
@override
String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색';
String get homeSubtitle => 'Paste a supported URL or search by name';
@override
String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs';
@@ -125,6 +125,13 @@ class AppLocalizationsKo extends AppLocalizations {
return '확장 기능을 사용: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
@@ -202,10 +209,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsConcurrentWarning => '동시에 다수의 음반을 다운로드하면 속도 제한이 발생할 수 있습니다';
@override
String get optionsExtensionStore => '확장 기능 스토어';
String get optionsExtensionStore => 'Extension Repo';
@override
String get optionsExtensionStoreSubtitle => '탐색 메뉴에 스토어 탭 표시';
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
@override
String get optionsCheckUpdates => '업데이트 확인';
@@ -279,7 +286,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get extensionsUninstall => '삭제';
@override
String get storeTitle => '확장 기능 스토어';
String get storeTitle => 'Extension Repo';
@override
String get storeSearch => '확장 기능 검색';
@@ -323,6 +330,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutPCSource => 'PC 소스 코드';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => '문제 신고';
@@ -908,6 +918,17 @@ class AppLocalizationsKo extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1295,7 +1316,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
String get extensionDefaultProvider => 'Default (Deezer)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
@@ -1395,6 +1416,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
@@ -2066,7 +2094,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2347,7 +2375,8 @@ class AppLocalizationsKo extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
+37 -8
View File
@@ -21,13 +21,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get navSettings => 'Settings';
@override
String get navStore => 'Store';
String get navStore => 'Repo';
@override
String get homeTitle => 'Home';
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
String get homeSubtitle => 'Paste a supported URL or search by name';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -127,6 +127,13 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -209,10 +216,10 @@ class AppLocalizationsNl extends AppLocalizations {
'Parallel downloaden kan leiden tot rate-limiting';
@override
String get optionsExtensionStore => 'Extension Store';
String get optionsExtensionStore => 'Extension Repo';
@override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
@override
String get optionsCheckUpdates => 'Check for Updates';
@@ -289,7 +296,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get extensionsUninstall => 'Uninstall';
@override
String get storeTitle => 'Extension Store';
String get storeTitle => 'Extension Repo';
@override
String get storeSearch => 'Search extensions...';
@@ -334,6 +341,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -926,6 +936,17 @@ class AppLocalizationsNl extends AppLocalizations {
String get providerPriorityInfo =>
'If a track is not available on the first provider, the app will automatically try the next one.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
@@ -1315,7 +1336,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
String get extensionDefaultProvider => 'Default (Deezer)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
@@ -1415,6 +1436,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'No extensions with download provider';
@@ -2086,7 +2114,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2367,7 +2395,8 @@ class AppLocalizationsNl extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
File diff suppressed because it is too large Load Diff
+48 -20
View File
@@ -21,13 +21,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get navSettings => 'Настройки';
@override
String get navStore => 'Магазин';
String get navStore => 'Repo';
@override
String get homeTitle => 'Главная';
@override
String get homeSubtitle => 'Вставьте ссылку Spotify или ищите по названию';
String get homeSubtitle => 'Paste a supported URL or search by name';
@override
String get homeSupports =>
@@ -129,6 +129,13 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Используется расширение: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Нажмите Deezer или Spotify для возврата с расширения';
@@ -214,11 +221,10 @@ class AppLocalizationsRu extends AppLocalizations {
'Параллельные загрузки могут вызвать ограничение скорости';
@override
String get optionsExtensionStore => 'Магазин расширений';
String get optionsExtensionStore => 'Extension Repo';
@override
String get optionsExtensionStoreSubtitle =>
'Показывать вкладку Магазин в гл. меню';
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
@override
String get optionsCheckUpdates => 'Проверить обновления';
@@ -295,7 +301,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get extensionsUninstall => 'Удалить';
@override
String get storeTitle => 'Магазин расширений';
String get storeTitle => 'Extension Repo';
@override
String get storeSearch => 'Поиск расширений...';
@@ -340,6 +346,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutPCSource => 'Исходный код ПК версии';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Сообщить о проблеме';
@@ -626,9 +635,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.';
}
@@ -681,9 +690,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалено $count $_temp0';
}
@@ -940,6 +949,17 @@ class AppLocalizationsRu extends AppLocalizations {
String get providerPriorityInfo =>
'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Встроенные';
@@ -1140,9 +1160,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count треков',
one: '1 трек',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0';
}
@@ -1336,7 +1356,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'По умолчанию (Deezer/Spotify)';
String get extensionDefaultProvider => 'Default (Deezer)';
@override
String get extensionDefaultProviderSubtitle =>
@@ -1439,6 +1459,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get extensionsDownloadPrioritySubtitle =>
'Установка порядок сервисов скачивания';
@override
String get extensionsFallbackTitle => 'Fallback Extensions';
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
@override
String get extensionsNoDownloadProvider =>
'Нет расширений с провайдером загрузки';
@@ -1642,9 +1669,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.';
}
@@ -1666,9 +1693,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0';
}
@@ -1899,9 +1926,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return '$_temp0';
}
@@ -2055,9 +2082,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count минут',
one: '1 минуту',
many: '$count минут',
few: '$count минуты',
one: '$count минуту',
);
return '$_temp0 назад';
}
@@ -2068,9 +2095,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count часов',
one: '1 час',
many: '$count часов',
few: '$count часа',
one: '$count час',
);
return '$_temp0 назад';
}
@@ -2136,7 +2163,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Просмотрите вкладку Магазина, чтобы найти полезные расширения';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2420,7 +2447,8 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackConvertFormat => 'Переконвертировать формат';
@override
String get trackConvertFormatSubtitle => 'Конвертировать в MP3 или Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Конвертировать аудио';
@@ -2551,9 +2579,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count треков',
one: '1 трек',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0';
}
@@ -2670,9 +2698,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Отправить $count $_temp0';
}
@@ -2687,9 +2715,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Конвертировать $count $_temp0';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1336 -20
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -158,6 +158,14 @@
}
}
},
"optionsDefaultSearchTab": "Default Search Tab",
"@optionsDefaultSearchTab": {
"description": "Title for the preferred default search tab setting"
},
"optionsDefaultSearchTabSubtitle": "Choose which tab opens first for new search results.",
"@optionsDefaultSearchTabSubtitle": {
"description": "Subtitle for the preferred default search tab setting"
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
@@ -422,6 +430,10 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
@@ -1203,6 +1215,18 @@
"@providerPriorityInfo": {
"description": "Info tip about fallback behavior"
},
"providerPriorityFallbackExtensionsTitle": "Extension Fallback",
"@providerPriorityFallbackExtensionsTitle": {
"description": "Section title for choosing which download extensions can be used as fallback providers"
},
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
"@providerPriorityFallbackExtensionsDescription": {
"description": "Section description for extension fallback selection"
},
"providerPriorityFallbackExtensionsHint": "Only enabled extensions with download-provider capability are listed here.",
"@providerPriorityFallbackExtensionsHint": {
"description": "Hint below the extension fallback selection list"
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
@@ -1857,6 +1881,14 @@
"@extensionsDownloadPrioritySubtitle": {
"description": "Subtitle for download priority"
},
"extensionsFallbackTitle": "Fallback Extensions",
"@extensionsFallbackTitle": {
"description": "Setting and page title for choosing which download extensions can be used during fallback"
},
"extensionsFallbackSubtitle": "Choose which installed download extensions can be used as fallback",
"@extensionsFallbackSubtitle": {
"description": "Subtitle for download fallback extensions menu"
},
"extensionsNoDownloadProvider": "No extensions with download provider",
"@extensionsNoDownloadProvider": {
"description": "Empty state - no download providers"
+4
View File
@@ -362,6 +362,10 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
+1356 -40
View File
File diff suppressed because it is too large Load Diff
+1349 -33
View File
File diff suppressed because it is too large Load Diff
+1325 -9
View File
File diff suppressed because it is too large Load Diff
+1278 -39
View File
File diff suppressed because it is too large Load Diff
+1325 -9
View File
File diff suppressed because it is too large Load Diff
+1325 -9
View File
File diff suppressed because it is too large Load Diff
+1325 -9
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -362,6 +362,10 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
+1331 -15
View File
File diff suppressed because it is too large Load Diff
+1336 -20
View File
File diff suppressed because it is too large Load Diff
+1595 -279
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -362,6 +362,10 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
+1326 -10
View File
File diff suppressed because it is too large Load Diff
+1326 -10
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -20,6 +20,7 @@ const List<Locale> filteredSupportedLocales = <Locale>[
Locale('pt', 'PT'),
Locale('ja'),
Locale('tr'),
Locale('uk'),
];
/// Set of locale codes for quick lookup.
@@ -31,4 +32,5 @@ const Set<String> filteredLocaleCodes = <String>{
'pt_PT',
'ja',
'tr',
'uk',
};
+11
View File
@@ -33,7 +33,9 @@ class AppSettings {
final bool askQualityBeforeDownload;
final bool enableLogging;
final bool useExtensionProviders;
final List<String>? downloadFallbackExtensionIds;
final String? searchProvider;
final String defaultSearchTab;
final String? homeFeedProvider;
final bool separateSingles;
final String singleFilenameFormat;
@@ -108,7 +110,9 @@ class AppSettings {
this.askQualityBeforeDownload = true,
this.enableLogging = false,
this.useExtensionProviders = true,
this.downloadFallbackExtensionIds,
this.searchProvider,
this.defaultSearchTab = 'all',
this.homeFeedProvider,
this.separateSingles = false,
this.singleFilenameFormat = '{title} - {artist}',
@@ -170,8 +174,11 @@ class AppSettings {
bool? askQualityBeforeDownload,
bool? enableLogging,
bool? useExtensionProviders,
List<String>? downloadFallbackExtensionIds,
bool clearDownloadFallbackExtensionIds = false,
String? searchProvider,
bool clearSearchProvider = false,
String? defaultSearchTab,
String? homeFeedProvider,
bool clearHomeFeedProvider = false,
bool? separateSingles,
@@ -232,9 +239,13 @@ class AppSettings {
enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders:
useExtensionProviders ?? this.useExtensionProviders,
downloadFallbackExtensionIds: clearDownloadFallbackExtensionIds
? null
: (downloadFallbackExtensionIds ?? this.downloadFallbackExtensionIds),
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
defaultSearchTab: defaultSearchTab ?? this.defaultSearchTab,
homeFeedProvider: clearHomeFeedProvider
? null
: (homeFeedProvider ?? this.homeFeedProvider),
+7
View File
@@ -35,7 +35,12 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
downloadFallbackExtensionIds:
(json['downloadFallbackExtensionIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
searchProvider: json['searchProvider'] as String?,
defaultSearchTab: json['defaultSearchTab'] as String? ?? 'all',
homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
singleFilenameFormat:
@@ -105,7 +110,9 @@ Map<String, dynamic> _$AppSettingsToJson(
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders,
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
'searchProvider': instance.searchProvider,
'defaultSearchTab': instance.defaultSearchTab,
'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles,
'singleFilenameFormat': instance.singleFilenameFormat,
+4
View File
@@ -16,12 +16,14 @@ class Track {
final int duration;
final int? trackNumber;
final int? discNumber;
final int? totalDiscs;
final String? releaseDate;
final String? deezerId;
final ServiceAvailability? availability;
final String? source;
final String? albumType;
final int? totalTracks;
final String? composer;
final String? itemType;
const Track({
@@ -37,12 +39,14 @@ class Track {
required this.duration,
this.trackNumber,
this.discNumber,
this.totalDiscs,
this.releaseDate,
this.deezerId,
this.availability,
this.source,
this.albumType,
this.totalTracks,
this.composer,
this.itemType,
});
+4
View File
@@ -19,6 +19,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
totalDiscs: (json['totalDiscs'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?,
deezerId: json['deezerId'] as String?,
availability: json['availability'] == null
@@ -29,6 +30,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
source: json['source'] as String?,
albumType: json['albumType'] as String?,
totalTracks: (json['totalTracks'] as num?)?.toInt(),
composer: json['composer'] as String?,
itemType: json['itemType'] as String?,
);
@@ -45,12 +47,14 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'duration': instance.duration,
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
'totalDiscs': instance.totalDiscs,
'releaseDate': instance.releaseDate,
'deezerId': instance.deezerId,
'availability': instance.availability,
'source': instance.source,
'albumType': instance.albumType,
'totalTracks': instance.totalTracks,
'composer': instance.composer,
'itemType': instance.itemType,
};
File diff suppressed because it is too large Load Diff
+115 -18
View File
@@ -20,7 +20,6 @@ class Extension {
final String name;
final String displayName;
final String version;
final String author;
final String description;
final bool enabled;
final String status;
@@ -45,7 +44,6 @@ class Extension {
required this.name,
required this.displayName,
required this.version,
required this.author,
required this.description,
required this.enabled,
required this.status,
@@ -73,7 +71,6 @@ class Extension {
displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '',
enabled: json['enabled'] as bool? ?? false,
status: json['status'] as String? ?? 'loaded',
@@ -124,7 +121,6 @@ class Extension {
String? name,
String? displayName,
String? version,
String? author,
String? description,
bool? enabled,
String? status,
@@ -149,7 +145,6 @@ class Extension {
name: name ?? this.name,
displayName: displayName ?? this.displayName,
version: version ?? this.version,
author: author ?? this.author,
description: description ?? this.description,
enabled: enabled ?? this.enabled,
status: status ?? this.status,
@@ -178,6 +173,26 @@ class Extension {
bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasHomeFeed => capabilities['homeFeed'] == true;
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
String? get preferredDownloadOutputExtension {
final value = capabilities['downloadOutputExtension'];
if (value is! String) return null;
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
List<String> get preservedNativeOutputExtensions {
final value = capabilities['preserveNativeOutputExtensions'];
if (value is! List) return const [];
final normalized = <String>[];
for (final item in value) {
if (item is! String) continue;
final trimmed = item.trim().toLowerCase();
if (trimmed.isEmpty) continue;
normalized.add(trimmed.startsWith('.') ? trimmed : '.$trimmed');
}
return normalized;
}
}
class SearchFilter {
@@ -481,8 +496,10 @@ class ExtensionState {
}
class ExtensionNotifier extends Notifier<ExtensionState> {
static const _builtInMetadataProviders = ['qobuz', 'tidal'];
AppLifecycleListener? _appLifecycleListener;
bool _cleanupInFlight = false;
Completer<void>? _initializationCompleter;
@override
ExtensionState build() {
@@ -520,6 +537,13 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> initialize(String extensionsDir, String dataDir) async {
if (state.isInitialized) return;
if (_initializationCompleter != null) {
await _initializationCompleter!.future;
return;
}
final completer = Completer<void>();
_initializationCompleter = completer;
state = state.copyWith(isLoading: true, error: null);
@@ -531,6 +555,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
error: null,
);
_log.i('Extension system disabled on this platform');
completer.complete();
_initializationCompleter = null;
return;
}
@@ -544,6 +570,32 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} catch (e) {
_log.e('Failed to initialize extension system: $e');
state = state.copyWith(isLoading: false, error: e.toString());
} finally {
if (!completer.isCompleted) {
completer.complete();
}
if (identical(_initializationCompleter, completer)) {
_initializationCompleter = null;
}
}
}
Future<void> waitForInitialization({
Duration timeout = const Duration(seconds: 30),
}) async {
if (state.isInitialized || !PlatformBridge.supportsExtensionSystem) {
return;
}
final future = _initializationCompleter?.future;
if (future == null) {
return;
}
try {
await future.timeout(timeout);
} on TimeoutException {
_log.w('Timed out waiting for extension initialization after $timeout');
}
}
@@ -566,6 +618,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
final list = await PlatformBridge.getInstalledExtensions();
final extensions = list.map((e) => Extension.fromJson(e)).toList();
state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
_log.d('Loaded ${extensions.length} extensions');
for (final ext in extensions) {
@@ -661,6 +714,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}).toList();
state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
if (!enabled && ext != null) {
final settings = ref.read(settingsProvider);
@@ -685,6 +739,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
Future<void> _reconcileDownloadProviderPriority() async {
if (state.providerPriority.isEmpty) {
return;
}
final sanitized = _sanitizeDownloadProviderPriority(state.providerPriority);
if (jsonEncode(sanitized) == jsonEncode(state.providerPriority)) {
return;
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
await PlatformBridge.setProviderPriority(sanitized);
state = state.copyWith(providerPriority: sanitized);
_log.d('Reconciled provider priority after extension update: $sanitized');
}
Future<bool> ensureSpotifyWebExtensionReady({
bool setAsSearchProvider = true,
}) async {
@@ -812,6 +883,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
List<String> _sanitizeDownloadProviderPriority(List<String> input) {
final allowed = getAllDownloadProviders().toSet();
final preferredOrder = getAllDownloadProviders();
final result = <String>[];
for (final provider in input) {
@@ -820,7 +892,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
for (final provider in const ['tidal', 'qobuz', 'deezer']) {
for (final provider in preferredOrder) {
if (!result.contains(provider)) {
result.add(provider);
}
@@ -847,10 +919,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
);
await PlatformBridge.setMetadataProviderPriority(priority);
} else {
priority = _sanitizeMetadataProviderPriority(
await PlatformBridge.getMetadataProviderPriority(),
);
final backendPriority =
await PlatformBridge.getMetadataProviderPriority();
priority = _sanitizeMetadataProviderPriority(backendPriority);
_log.d('Using default metadata provider priority: $priority');
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(priority),
);
await PlatformBridge.setMetadataProviderPriority(priority);
}
state = state.copyWith(metadataProviderPriority: priority);
@@ -896,7 +973,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz', 'deezer'];
final providers = ['tidal', 'qobuz'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasDownloadProvider) {
providers.add(ext.id);
@@ -906,17 +983,26 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllMetadataProviders() {
final providers = ['deezer', 'qobuz', 'tidal'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasMetadataProvider) {
providers.add(ext.id);
}
}
return providers;
final metadataExtensions = state.extensions
.where((ext) => ext.enabled && ext.hasMetadataProvider)
.toList();
final primarySearchMetadataExtensions = metadataExtensions
.where((ext) => ext.searchBehavior?.primary == true)
.map((ext) => ext.id);
final otherMetadataExtensions = metadataExtensions
.where((ext) => ext.searchBehavior?.primary != true)
.map((ext) => ext.id);
return [
...primarySearchMetadataExtensions,
..._builtInMetadataProviders,
...otherMetadataExtensions,
];
}
List<String> _sanitizeMetadataProviderPriority(List<String> input) {
final allowed = getAllMetadataProviders().toSet();
final preferredOrder = getAllMetadataProviders();
final result = <String>[];
for (final provider in input) {
@@ -925,7 +1011,18 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
for (final provider in const ['deezer', 'qobuz', 'tidal']) {
final hasPreferredExtension = preferredOrder.any(
(provider) => !_builtInMetadataProviders.contains(provider),
);
final hasSavedExtension = result.any(
(provider) => !_builtInMetadataProviders.contains(provider),
);
if (!hasSavedExtension && hasPreferredExtension) {
return List<String>.from(preferredOrder);
}
for (final provider in preferredOrder) {
if (!result.contains(provider)) {
result.add(provider);
}
+69 -4
View File
@@ -12,12 +12,13 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 9;
const _currentMigrationVersion = 10;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> {
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
static const Set<String> _searchTabValues = {'all', 'track', 'artist', 'album'};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -35,9 +36,23 @@ class SettingsNotifier extends Notifier<AppSettings> {
final prefs = await _prefs;
final json = prefs.getString(_settingsKey);
if (json != null) {
state = AppSettings.fromJson(
final loaded = AppSettings.fromJson(
Map<String, dynamic>.from(jsonDecode(json) as Map),
);
final sanitizedDownloadFallbackExtensionIds =
_sanitizeDownloadFallbackExtensionIds(
loaded.downloadFallbackExtensionIds,
);
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
loaded.defaultSearchTab,
);
state = loaded.copyWith(
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
clearDownloadFallbackExtensionIds:
loaded.downloadFallbackExtensionIds != null &&
sanitizedDownloadFallbackExtensionIds == null,
defaultSearchTab: sanitizedDefaultSearchTab,
);
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
@@ -50,6 +65,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
_syncLyricsSettingsToBackend();
_syncNetworkCompatibilitySettingsToBackend();
_syncExtensionFallbackSettingsToBackend();
}
void _syncLyricsSettingsToBackend() {
@@ -83,6 +99,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
});
}
void _syncExtensionFallbackSettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
PlatformBridge.setDownloadFallbackExtensionIds(
state.downloadFallbackExtensionIds,
).catchError((Object e) {
_log.w('Failed to sync extension fallback settings to backend: $e');
});
}
Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
@@ -111,8 +137,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
// Migration 7: YouTube is no longer a built-in service reset to Tidal
if (state.defaultService == 'youtube') {
// Migration 7/10: retired built-in services reset back to Tidal
if (state.defaultService == 'youtube' ||
state.defaultService == 'deezer') {
state = state.copyWith(defaultService: 'tidal');
}
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
@@ -165,6 +192,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
return 'US';
}
String _normalizeDefaultSearchTab(String value) {
final normalized = value.trim().toLowerCase();
if (_searchTabValues.contains(normalized)) return normalized;
return 'all';
}
Future<void> _normalizeSongLinkRegionIfNeeded() async {
final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
if (normalized == state.songLinkRegion) return;
@@ -172,6 +205,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings();
}
List<String>? _sanitizeDownloadFallbackExtensionIds(List<String>? ids) {
if (ids == null) {
return null;
}
final result = <String>[];
for (final id in ids) {
final normalized = id.trim();
if (normalized.isEmpty || result.contains(normalized)) {
continue;
}
result.add(normalized);
}
return result;
}
Future<void> _cleanupRetiredSpotifySettings() async {
final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
@@ -370,6 +419,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setDefaultSearchTab(String tab) {
state = state.copyWith(defaultSearchTab: _normalizeDefaultSearchTab(tab));
_saveSettings();
}
void setHomeFeedProvider(String? provider) {
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearHomeFeedProvider: true);
@@ -390,6 +444,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setDownloadFallbackExtensionIds(List<String>? extensionIds) {
final sanitized = _sanitizeDownloadFallbackExtensionIds(extensionIds);
state = state.copyWith(
downloadFallbackExtensionIds: sanitized,
clearDownloadFallbackExtensionIds:
extensionIds == null && state.downloadFallbackExtensionIds != null,
);
_saveSettings();
_syncExtensionFallbackSettingsToBackend();
}
void setSeparateSingles(bool enabled) {
state = state.copyWith(separateSingles: enabled);
_saveSettings();

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