Compare commits

..

286 Commits

Author SHA1 Message Date
github-actions[bot] fe72286273 chore: update AltStore source to v4.7.1 2026-07-01 18:42:31 +00:00
zarzet 2b8ec744dd feat(extensions): embed full metadata and cover after extension downloads
Replace duplicate genre/label-only embedding with a shared post-download
step that writes complete FLAC tags and optional cover art when the output
file is available locally.
2026-07-02 01:24:22 +07:00
zarzet 5f11f5b114 chore(release): bump version to 4.7.1+137 2026-07-02 01:24:22 +07:00
zarzet 61f62363b3 fix(convert): make convert bottom sheets draggable and scroll-controlled
Use DraggableScrollableSheet for single and batch convert flows and open
convert sheets with isScrollControlled so long option lists remain usable
on smaller screens.
2026-07-02 01:24:21 +07:00
zarzet 3278e32711 fix(extensions): default verification browser to in-app first
Prefer the in-app browser for signed-session verification challenges,
normalize invalid saved modes to the new default, and keep the help
dialog modal until the user explicitly dismisses it.
2026-07-02 01:24:21 +07:00
zarzet 0be6455d46 fix(download): hand off native worker verification to interactive queue
Detect verification-required failures from the Android native worker,
cancel the worker, and route the item back through the interactive
verification flow with the correct service identifier.
2026-07-02 01:24:21 +07:00
zarzet 0bf5a39a92 fix(ac4): reject truncated AC-4 sample entries safely
Validate audio sample entry header bounds before QuickTime v1
normalization and dac4 injection so malformed MP4 trees are left
unchanged or rejected instead of panicking on truncated boxes.
2026-07-02 01:24:21 +07:00
zarzet 5424648158 feat(audio): add dither and resampler options for lossless conversion
Let users choose FFmpeg dithering when reducing bit depth and SoXr or
SWR resampling when changing sample rate across single-track and batch
lossless conversion flows.
2026-07-02 01:24:20 +07:00
zarzet dcfd95f276 feat(extensions): manual verification help when browser launch fails
Expose a root navigator for global dialogs, show a fallback help sheet
with copy and reopen actions when verification URLs cannot launch, and
schedule the same prompt after a timeout during pending grants.
2026-07-02 01:24:20 +07:00
zarzet 4d6f7d8b08 l10n: add extension verification and lossless processing strings
Add localization keys for verification browser settings, manual
verification help dialog actions, and lossless conversion dithering and
resampler option labels.
2026-07-02 01:24:20 +07:00
zarzet 2c2cf8cdf8 fix(extensions): bootstrap and clear pending signed-session auth
Ensure pending auth requests are created when verification is needed but
missing, and clear them after signed-session grant completion or clear
so stale challenges do not block later verification flows.
2026-07-02 01:24:19 +07:00
zarzet 08c738dc69 feat(settings): add extension verification browser mode preference
Let users choose whether signed-session verification opens in the
external browser or in-app browser first, with automatic fallback to
the other mode when launch fails.
2026-07-02 01:24:19 +07:00
github-actions[bot] eb36b0bb7b chore: update AltStore source to v4.7.0 2026-06-30 20:59:56 +00:00
zarzet 3fd14e21eb feat(extension-repo): preserve package suffix from download URL
Detect .sflx and .spotiflac-ext from the registry download URL when saving
repo downloads, and extract findExtension so the destination path can use
the correct package extension.
2026-07-01 03:19:02 +07:00
zarzet 408895b607 fix(download): track verification retries per service
Key verification retry state by item and service so a completed challenge
for one provider does not block retries when another service also requires
verification for the same download.
2026-07-01 02:03:19 +07:00
zarzet 1a01147a95 fix(extensions): scope signed session files by endpoint and app context
Hash session cache paths from namespace, base URL, app version, and
platform so credentials do not leak across environments, and invalidate
stored sessions when that scope changes.
2026-07-01 02:03:14 +07:00
zarzet 8950907428 feat(extensions): route Deezer metadata through enabled extension
Prefer the enabled Deezer metadata extension over the built-in provider
when resolving album, track, and playlist metadata requests.
2026-07-01 02:03:10 +07:00
zarzet eb40a88437 fix(lyrics): sync provider priority to backend on save
Await backend lyrics provider sync when saving the priority page so
fetch order changes take effect immediately instead of only updating
local settings.
2026-06-30 11:13:23 +07:00
zarzet 7f82049beb fix(lyrics): prefer workers.dev LyricsPlus mirror first
Trim the LyricsPlus server list to the workers.dev and binimum mirrors
and update tests to match the new primary endpoint order.
2026-06-30 09:13:55 +07:00
zarzet c0c1d745f3 refactor: hoist lossless labels before async convert paths
Capture lossless conversion labels before await boundaries in queue,
downloaded album, and track metadata flows to avoid BuildContext usage
across async gaps.
2026-06-30 06:20:57 +07:00
zarzet c2b38a7c5a fix(banner): allow motion artwork video to mix with other audio
Enable mixWithOthers on HLS motion header playback so preview audio and
other apps are less likely to be ducked or interrupted.
2026-06-30 06:20:55 +07:00
zarzet ae8638a4b2 chore(player): log internal pause and audio interruption events
Add debug/info logging around audio focus interruptions, becoming-noisy
events, and user pause requests to make preview playback issues easier
to diagnose.
2026-06-30 06:20:51 +07:00
zarzet b864fafa82 feat(ui): stabilize album and playlist header layouts
Keep header metadata and action buttons visible while track lists are
still loading, disable download-all when empty, and show consistent
placeholder artwork when cover art is missing.
2026-06-30 06:20:47 +07:00
zarzet ee5ab1a751 fix(queue): resolve local album tracks by album_key
Load queue local album tracks from album_key when available and fall back
to album/artist matching with empty album_artist handling. Thread album_key
through grouped local album rows for more reliable navigation.
2026-06-30 06:20:37 +07:00
zarzet 64b884e27a fix(lyrics): sync provider settings to backend before fetch
Expose an awaitable lyrics settings sync on SettingsNotifier and call it
before metadata re-enrich, lyrics autofill, and queue/local embed flows so
the backend uses the latest provider order and fetch options.
2026-06-30 06:20:25 +07:00
zarzet dc8bb2cbc2 feat(extension-health): lengthen cache TTL and honor per-check minimum
Raise default extension health cache to 10 minutes with a 1-minute floor
and a shorter TTL for unknown status. Mirror the TTL rules in the Go
backend and stop force-refreshing health checks from the download
service picker on every open.
2026-06-30 06:20:10 +07:00
zarzet d882fc292c l10n: localize settings, store, and metadata UI strings
Wire remaining hardcoded strings in about, download region picker, file
organization settings, extension repo snackbars, release type badges,
metadata queue actions, online cover labels, and update changelog fallback.
2026-06-30 05:10:37 +07:00
zarzet 5dc0980ced l10n: localize audio conversion labels and confirmations
Pass localized lossless conversion labels through shared helpers and
replace hardcoded capped-lossless confirmation text in single-track and
batch convert flows across metadata, queue, and album screens.
2026-06-30 05:10:36 +07:00
zarzet 1cd668c869 l10n: localize library settings and announcements
Move hardcoded strings in library settings, announcement link errors,
and unknown track title/artist fallbacks to AppLocalizations. Sync
locale-dependent fallback strings from MainShell.
2026-06-30 05:10:34 +07:00
zarzet a827ebf6f4 l10n: add localization keys for hardcoded UI strings
Add English template strings and Indonesian translations for playback,
audio conversion, library settings, metadata actions, store snackbars,
SongLink region names, and other previously hardcoded user-facing text.
Regenerate AppLocalizations from the updated ARB files.
2026-06-30 03:40:13 +07:00
zarzet 3917ae02e2 feat: apply lossless conversion quality cap to album screen batch convert
Wire bit depth/sample rate caps and real converted-quality persistence
into the downloaded/local album batch conversion paths, matching the
queue tab behavior.
2026-06-29 06:47:36 +07:00
zarzet bd14c7dc63 fix(go): widen lyrics priority grace to 5s so priority provider wins
Apple Music (priority) was losing to LRCLIB when slightly slower than the
1.2s grace window. Widen to 5s so a slower priority provider still wins,
while still bounding the wait if it hangs/fails.
2026-06-29 06:47:04 +07:00
zarzet e0e28aee38 fix: preview player lifecycle and minor safety cleanups
- search_screen caches PreviewPlayerController in initState to avoid
  ref access during dispose
- extension_provider: convert enabledExtensions/searchProviders getters
  to methods
- artist_screen: drop redundant null assertions on motion banner inputs
2026-06-29 06:46:47 +07:00
zarzet 1550eedc12 feat: reorderable up-next queue + playback stability
- Up Next sheet supports drag-to-reorder via ReorderableListView with
  drag handles; MusicPlayerHandler.moveQueueItem keeps current track active
- Active lyric line centers higher in viewport (0.35) so it sits visually
  centered instead of leaning low
- Audio focus rework: explicit music AudioContext, ignore duck
  interruptions, drop the fragile ignore-complete timer, recover playback
  on transient stop/complete during track switches
2026-06-29 06:46:31 +07:00
zarzet b2074dfd02 feat: cap bit depth/sample rate on lossless conversion + WAV/AIFF
- LosslessConversionQuality model with bit depth/sample rate caps,
  applied only when they reduce source quality
- FFmpegService probes sample rate and appends codec-specific args
  (-ar, -sample_fmt, -bits_per_raw_sample) for FLAC/ALAC/WAV/AIFF
- Batch + single-track convert sheets expose quality cap options
- Persist real converted bit depth/sample rate to history/library DB
- track_metadata: recognize and convert to WAV/AIFF targets
- convertedAudioQualityLabel reflects actual output quality
2026-06-29 06:46:19 +07:00
zarzet e9171d6f21 feat(ui): hide audio quality badge on downloading queue items 2026-06-29 06:45:54 +07:00
zarzet ef60bba2e1 feat(ui): redesign local/downloaded album and folder screen headers
- Consistent header design across all local screens: blurred cover,
  black overlay, bottom gradient, centered square cover, adaptive title,
  inline meta line, Play + Shuffle buttons
- Height normalized to 0.6x (clamp 400-580) everywhere
2026-06-28 22:23:08 +07:00
zarzet 12fb942f16 feat: playback queue, preview exclusivity, player bug fixes
- PlaybackController with queue methods for albums/library/playlists
- Library tab play builds merged queue (downloaded + local together)
- Preview vs main player exclusivity
- Preview stops on bottom-nav tab switch
- Duration 0:00 fix, deleted track cleanup, Up Next sheet
- Animation utilities improvements
2026-06-28 22:22:51 +07:00
zarzet 3a2481e8b2 feat: add new playback experience and media integration 2026-06-28 22:22:33 +07:00
zarzet bede5ae8d7 feat(go): verification early-abort in fallback + album metadata from tracks
- DownloadWithExtensionFallback now immediately surfaces verification_required
  when any provider needs verification (availability + download stages),
  instead of letting later providers mask it
- classifyDownloadErrorType treats 'session is not authenticated' as
  verification_required (Go + Dart side)
- parseExtensionAlbumValue.withTrackFallbacks() derives album artist,
  release date, and audio traits from tracks when album-level missing
- albumAudioTraitsFromTracks detects dolby_atmos/hi_res_lossless/lossless
  from per-track audio_quality/audio_modes fields
- parseBitDepthSampleRate parses '24bit/96kHz' style quality labels
2026-06-28 22:16:36 +07:00
zarzet 445b186e3b fix(go): classify transient timeout/5xx in extension health as unknown (grey) 2026-06-28 22:15:55 +07:00
zarzet 354fe61b85 refactor(track-provider): rework verification retry, parse preview/header video
Replace the pending-verification-search bookkeeping with an inline open-and-wait flow that retries the custom search once the session grant arrives (with timeout). Parse preview_url into Track, and carry headerVideoUrl through TrackState for URL-resolved tracks/albums/artists.
2026-06-28 06:07:15 +07:00
zarzet 95f5ae610e feat(banner): HLS motion-artwork header banners and audio-quality badges
Add a MotionHeaderBanner (video_player) that plays a looping muted HLS header video with a static image fallback for artist, album, and playlist screens. The Go backend now exposes header_video, header_image, and audio_traits from extensions. Album/playlist headers show the release year and Dolby Atmos / Lossless badges inline, full date and song count in a footer, a centered square cover when no video is present, and a full-bleed video when one is.
2026-06-28 06:06:54 +07:00
zarzet 2e806a28b9 fix(ui): prevent home search skeleton row from overflowing on narrow screens 2026-06-28 06:06:32 +07:00
zarzet 2ab0350733 feat(preview): play short track preview snippets in lists
Add a preview player provider and PreviewButton, a previewUrl field on Track (parsed from search and local redownload), preview buttons in search results, and stop the preview on navigation/tab change via per-tab navigator observers. Includes preview play/stop/unavailable localization strings.
2026-06-28 06:06:12 +07:00
zarzet ce813bc216 feat(extension-health): cache results, force refresh, treat lookup errors as transient
Cache health results after each check, add a force flag to bypass the cache when the download picker opens or a service is selected, guard stale concurrent results via a per-extension request serial, and treat DNS lookup failures as indeterminate/transient.
2026-06-28 06:05:48 +07:00
zarzet 21fe047e00 feat(ui): polish album folder structure picker bottom sheet
Add a localized description header and Material 3 styling (rounded
corners, scrollable layout, surface color) to the album folder structure
picker in Files settings.
2026-06-27 10:46:04 +07:00
zarzet 8558450378 fix(lyrics): improve provider fallback and health handling 2026-06-27 06:35:32 +07:00
zarzet f9e68b628d chore: bump version to 4.7.0+136 2026-06-27 02:21:44 +07:00
zarzet 50509d0a16 chore: clean up redundant comments across Go backend and Flutter sources 2026-06-26 22:57:03 +07:00
zarzet c1c0494912 perf: optimize queue/library pagination, counts, scan, and extension runtime
- Queue/Library: switch from growing-limit refetch to offset-based append pagination per filter/search/sort, with page cache reassembly and protected-page eviction to avoid top-of-list gaps

- Queue/Library: watch only the active filter page instead of all/singles/albums simultaneously; filter mode follows PageView swipe and tab tap

- library_database: combine three union-based count queries into a single round-trip

- library_scan: parallel scan of non-CUE audio files with a bounded 2-4 worker pool, preserving order, progress, and cancellation; CUE handling stays sequential

- extension_manager: cache compiled goja.Program per extension and RunProgram per isolated download instead of re-reading and re-parsing index.js
2026-06-26 22:39:22 +07:00
zarzet 58e615462c feat(ui): add Beta badge to Backup & Restore settings entry
Extract the BETA pill into a reusable BetaBadge widget in settings_group.dart and add titleTrailing support to SettingsItem. Show the badge on the Backup & Restore entry in the settings list, and reuse the shared widget in the download settings page (removing the duplicated private badge).
2026-06-26 22:16:21 +07:00
zarzet f0bf769f0d refactor(ui): make backup/restore page consistent with other settings
Replace the custom _ActionCard/SwitchListTile layout with the shared SettingsSectionHeader + SettingsGroup + SettingsItem/SettingsSwitchItem components used across the other settings pages, so spacing, grouping and switch styling match. Busy state shows an inline progress indicator on the action row.
2026-06-26 22:12:07 +07:00
zarzet 423d50cfb5 build(android): bump Java to 25 and targetSdk to 37
Raise sourceCompatibility/targetCompatibility to Java 25 and Kotlin jvmTarget to JVM_25 across app and subprojects, and CI java-version to 25 (Kotlin 2.3.21 supports JVM_25). Bump targetSdk to 37 to match compileSdk. Building locally requires Flutter to use JDK 25 (flutter config --jdk-dir).
2026-06-26 22:08:20 +07:00
zarzet 2f4a62e03c build(android): bump compileSdk to 37 and pin file_picker to beta.5
Pin file_picker to exact 12.0.0-beta.5 since beta.7's published artifact fails to compile on Android. Set compileSdk = 37 so receive_sharing_intent 1.9.0 (compiles against SDK 37) builds against a stable platform.
2026-06-26 21:40:04 +07:00
zarzet e64bea41e6 fix(deps): keep file_picker on 12.x beta to stay win32 6 compatible
file_picker has no stable release compatible with win32 ^6.x (required by device_info_plus/share_plus/connectivity_plus). Its Windows FFI code is compiled even for Android builds, so a win32 override breaks compilation. Revert file_picker to 12.0.0-beta and restore the original pickFile usage. Other Flutter/Go dependency updates remain on latest stable.
2026-06-26 21:33:13 +07:00
zarzet f0acda0f01 feat(network): add opt-in allow local/private network setting
Add a setting that relaxes the SSRF guard so extensions and built-in network code can reach private/local/loopback targets, for users routing traffic through a local proxy or custom DNS. Disabled by default. Wired end-to-end: Go backend (SetAllowPrivateNetwork toggles isPrivateIP guard), Android/iOS platform bridge, Dart settings model/provider, and a toggle in Download settings.
2026-06-26 20:29:15 +07:00
zarzet af4e4561ec chore(deps): update Flutter and Go dependencies to latest stable
Flutter: bump connectivity_plus, device_info_plus, share_plus, receive_sharing_intent, path_provider, flutter_local_notifications to latest stable. Revert file_picker from 12.0.0-beta to 11.0.2 stable and migrate pickFile (singular) to pickFiles. Add win32 ^6.0.1 dependency_overrides to resolve the win32 5 vs 6 conflict between file_picker stable and device_info_plus (Windows-desktop-only transitive dep, not used on mobile targets). Go: update goja, golang.org/x/mobile, golang.org/x/tools, regexp2/v2 to latest.
2026-06-26 20:26:43 +07:00
zarzet 1787059f42 feat(backup): include installed extensions and settings in backup/restore
Back up the store registry URL plus each installed extension (id, version, enabled flag and settings) and restore them on a new device by reinstalling from the store and re-applying settings. Secret-flagged settings (tokens/API keys) are excluded by default behind an opt-in 'Include extension credentials' toggle. Device-bound signed sessions are never backed up. Settings are merged on restore so omitted secrets are not wiped; failed reinstalls are reported.
2026-06-26 20:11:51 +07:00
zarzet b2705cb2ae feat(settings): add backup and restore for settings, history and library
Add a Backup & Restore page that exports app settings, download history, liked tracks, wishlist, playlists (with cover images) and favorite artists into a single JSON file, and restores them on another device. Settings restore preserves device-specific storage location (SAF tree URI, download dir). Includes EN strings and ID translations.
2026-06-26 19:54:20 +07:00
zarzet f236d72a19 fix(download): detect actual output format from audio codec on fallback
When a fallback provider returns a non-FLAC container (e.g. YouTube Opus) and neither an explicit extension field nor a recognizable path suffix is available (such as SAF content URIs), infer the output extension from the backend-probed audio codec. Prevents Opus/MP3/AAC downloads from being mislabeled and embedded as FLAC, which left metadata empty.
2026-06-26 19:06:04 +07:00
zarzet cf270a36ff feat(library): add rename action to playlist folder screen
Add an edit button in the playlist folder app bar that opens a rename dialog with validation, calling renamePlaylist on the collections provider.
2026-06-26 18:35:41 +07:00
zarzet 6d932386b0 feat(download): add {playlist_position} filename placeholder
Add a playlist position token usable in filename templates, plumbed from the playlist screen through the download queue to the Go backend. Auto-prefix playlist downloads with the position when the template lacks the token. Includes Go filename test.
2026-06-26 18:35:32 +07:00
zarzet 9c054b9e3a feat: handle extension verification and retry-after on download
Classify verification-required and rate-limit errors from extensions, propagate Retry-After seconds through the download fallback, report session-grant success/failure, and add a completeGrant action fallback in the runtime.
2026-06-26 07:12:12 +07:00
zarzet d9f0007a2d fix: navigate to correct artist when tapping artist name
Resolve artist taps by id when available and fall back to name-similarity matching instead of blindly picking the first search result. Pass provider id at more call sites and map multiple artist ids positionally to their names.
2026-06-26 07:11:54 +07:00
zarzet ee35f52baf feat: extension runtime and provider improvements 2026-06-26 04:14:51 +07:00
zarzet 21347420f3 feat(download): AC-4 passthrough support
Decrypt AC-4 via the FFmpeg mov muxer with a -f mov fallback, then repair the output to a standards-compliant ISO MP4: inject the dac4 config box from the encrypted source, normalize the QuickTime container/sample entry, and write iTunes metadata (incl. cover and lyrics) natively. Codec-keyed and generic, so it applies to any extension that returns AC-4 streams. Wired through PlatformBridge/MainActivity for both SAF and local decrypt paths.
2026-06-23 02:44:08 +07:00
zarzet 26987459f3 fix(metadata): cap oversized cover art and support QuickTime/MP4 tags
Re-encode/downscale cover art that exceeds the FLAC 24-bit picture block limit (go-flac silently truncated it into a corrupt file). Locate ilst in both ISO and QuickTime-style meta atoms, and skip freeform tags gracefully when no iTunes container exists.
2026-06-23 02:41:42 +07:00
zarzet 897388853b chore: move website to spotiflacapp/Website repo 2026-06-22 17:25:31 +07:00
zarzet ef52332b8b feat(replaygain): add Opus R128 gain tags
Write R128_TRACK_GAIN/R128_ALBUM_GAIN (Q7.8, ref -23 LUFS) alongside the existing REPLAYGAIN_* tags for .opus files so spec-compliant Opus players (RFC 7845) apply gain. Covers per-track and album embed during download (incl. SAF) and standalone re-scan.
2026-06-20 02:24:50 +07:00
zarzet 1489378ffd fix(android): disable Impeller on Sony audio players and Vivante GPUs
The GL renderer string is empty when Flutter shell args are built (no GL context yet), so the GPU-pattern check never matched Sony Walkman devices and Impeller crashed in the Vivante driver's glLinkProgram. Match MANUFACTURER 'SonyAudio' (distinct from Xperia 'Sony') and add Vivante GC GPU patterns.
2026-06-19 00:50:52 +07:00
zarzet ccc93f881a fix(metadata): write M4A ISRC/label natively and read all edited fields from file
FFmpeg's MP4 muxer drops ISRC and label, so write them as iTunes freeform atoms natively after every M4A embed pass. The metadata screen now reads all edited fields from the file on load (file is the source of truth), fixing edited values reverting to stale cached values after reopening.
2026-06-14 15:00:34 +07:00
zarzet ded8b68098 fix(download): honor requested quality when fallback provider recognizes it 2026-06-14 14:12:37 +07:00
zarzet 983be8b37a feat(queue): tap a failed download to view its error details
Tapping a failed download tile (grid or list) now opens a dialog showing the full error message with retry and remove actions.
2026-06-14 05:26:30 +07:00
github-actions[bot] 7b22bbf25f chore: update AltStore source to v4.6.0 2026-06-13 19:43:33 +00:00
zarzet 06f2b9ec97 ci(ios): strip CRLF from ffmpeg plugin scripts before pod install
The ffmpeg_kit_flutter_new_full pub package ships setup_ios.sh with CRLF line endings, so its podspec prepare_command failed with '/bin/bash^M: bad interpreter'. Normalize the plugin's shell scripts in the pub cache before building iOS.
2026-06-14 02:28:57 +07:00
zarzet 7fee4cea4f chore: bump version to 4.6.0 2026-06-14 02:08:52 +07:00
zarzet 526897b23b feat(playlist): blurred backdrop with full cover in playlist header
Replaces the cropped BoxFit.cover header with a blurred cover backdrop plus the full square cover centered, so covers with baked-in text are no longer awkwardly cropped. Title, track count and actions now sit in one centered column that adapts to header height.
2026-06-13 20:47:12 +07:00
zarzet c10c2a290c feat(ui): add bottom inset so scrollable content clears the transparent navbar 2026-06-13 20:31:39 +07:00
zarzet fb5204b0a6 fix(metadata): use high-res cover in track metadata header 2026-06-13 20:31:24 +07:00
zarzet 9db4048bc0 feat(library): show active downloads inside the library grid
Active downloads now render as the first tiles of the library list/grid instead of a separate top section, with a compact Downloading header that animates in/out. Completed items hand off seamlessly via a short-lived bridge tile (with cover precache) so the song never blinks out, and the order is reversed so the soonest-to-finish sits next to where it lands.
2026-06-13 20:31:13 +07:00
zarzet 63c68b4d4d fix(download): honor selected provider when it equals the track source
When the chosen download service matched the track's source extension it was skipped in both the source preflight and the fallback loop, so downloads silently fell back to another provider. It is now attempted in the loop, and an explicitly selected provider bypasses the fallback allow-list.
2026-06-13 20:31:02 +07:00
zarzet 953ef37882 fix(download): request fallback provider's own highest quality 2026-06-13 16:29:30 +07:00
zarzet da85a2dcc2 feat(ui): reduce bottom navbar height 2026-06-13 16:09:54 +07:00
zarzet 49869792cf chore: trim redundant comments 2026-06-13 15:37:00 +07:00
zarzet fb2dda1ed1 feat(ui): frosted translucent bottom navbar 2026-06-13 15:36:59 +07:00
zarzet fad4c4ea36 feat(lyrics): show actual lyrics source in metadata 2026-06-13 15:36:47 +07:00
zarzet 6b5345a6e5 fix(downloads/extensions): iOS background task, serialize extension mutations, safer batch convert sheet
- iOS: begin/end UIBackgroundTask while a download queue is active so in-flight downloads survive backgrounding for the limited window iOS allows

- extensions: serialize install/upgrade/remove in the Go manager (mutationMu) and in the Dart store provider to stop concurrent goja VM teardown/reload from hard-crashing the app

- main: add runZonedGuarded + FlutterError/PlatformDispatcher onError so uncaught Dart errors are logged, not fatal

- batch convert sheet: precompute localized title/label before showModalBottomSheet to avoid Localizations lookup via a deactivated context
2026-06-13 02:42:23 +07:00
zarzet ca413a16fa fix(ui): center modals on large screens, modernize edit-metadata + convert sheets, themed badges, fix artist skeleton and format-editor crash
- app: clear displayFeatures so bottom sheets/dialogs center on large/foldable screens

- edit metadata sheet: card sections, modern label-above inputs, elegant collapsible headers, removed title icon

- convert + batch convert: modern card-based sheets; shared BatchConvertSheet widget

- queue: keep selection toolbar hidden until modal close animation finishes

- 24-bit and In Library badges now use primary dynamic color

- artist skeleton: remove duplicate name/listeners lines, keep cover placeholder

- files settings: own filename-format controller in a StatefulWidget to fix use-after-dispose crash
2026-06-13 02:08:06 +07:00
zarzet b8b670642c feat(audio): add WAV and AIFF support + settings-style metadata menu
WAV/AIFF: library scan, quality probe, native tag read/write via embedded ID3 chunk (RIFF id3 / AIFF ID3), cover art, ReadFileMetadata, ExtractLyrics, and FLAC<->WAV/AIFF conversion (PCM, bit-depth preserved via ffprobe). Treat WAV/AIFF as lossless across all convert sheets (no bitrate picker, Lossless labels) via isLosslessConversionTarget. Native MIME maps for SAF. Redesign the track metadata three-dot menu to a settings-style grouped card with a single divider above Share.
2026-06-12 21:10:37 +07:00
zarzet 2a2e2924eb feat(lyrics,replaygain): add LyricsPlus provider and ReplayGain batch scanning
LyricsPlus (KPOE): word-by-word synced lyrics with multi-server failover, converted to enhanced LRC. ReplayGain: standalone EBU R128 (re)scan writing REPLAYGAIN_TRACK_* tags via native writers or FFmpeg, with batch action in queue/album screens and SAF support.
2026-06-12 01:59:26 +07:00
zarzet adea3de737 chore(deps): update Flutter and Go dependencies
Bump riverpod, go_router, sqflite, permission_handler, ffmpeg_kit, flutter_local_notifications, json_annotation and riverpod_generator/lint to stable; refresh go.mod/go.sum via go get -u.
2026-06-12 01:55:58 +07:00
zarzet 7d300a39c9 refactor: generalize Tidal-specific naming to legacy/DASH terminology
- Rename downloadProviderMatchesBuiltIn -> downloadProviderReplacesLegacyProvider

- Rename Tidal DASH ffmpeg helpers and lossy format pickers to generic names

- Add utils.decryptCTRSegments crypto API + raw/bytes file read path in extension runtime

- Update l10n strings/descriptions to drop hardcoded service names

- Bump version to 4.5.7+134
2026-06-11 01:08:20 +07:00
zarzet 688a5f2add fix(l10n): remove redundant ICU plural categories causing gen-l10n warnings 2026-06-07 05:30:51 +07:00
zarzet d736e5aafe refactor(download): remove concurrent download option
The download API only permits one request at a time, so parallel
downloads are removed to avoid wasted/blocked API calls. Downloads
now always run sequentially (one track at a time).

- Drop concurrentDownloads from AppSettings + JSON serialization
- Remove setConcurrentDownloads and the settings UI (1-5 chips + warning)
- Strip optionsConcurrent* l10n keys from all ARBs and regenerate
- Rework queue worker into _processQueueSequential (single active download)
- Update marketing copy and adjust tests
2026-06-06 21:58:45 +07:00
zarzet 3a536ad348 chore(about): credit Mickael81 as French translator 2026-06-04 22:46:56 +07:00
zarzet 5dedeb4971 fix(android): override predictive-back page transition
Flutter's default Android route transition (PredictiveBackPageTransitionsBuilder) mis-routes the predictive-back gesture to a nested Navigator instead of the topmost route (flutter#152323), popping the page behind a root modal instead of closing the modal first. This regressed after the Flutter upgrade in 4.5.6. Force FadeForwardsPageTransitionsBuilder on Android (the same non-gesture animation that builder delegates to) so back closes modals/sheets/dialogs first, then pops the page - restoring 4.5.5 behavior. Keep Cupertino transitions on iOS/macOS.
2026-06-04 22:46:45 +07:00
zarzet 7624e24ea6 fix(queue): simplify queue header and rate-limit indicator layout 2026-06-04 21:03:12 +07:00
zarzet 7b248d8ab4 feat(l10n): enable French and German locales 2026-06-04 21:03:02 +07:00
zarzet fdb2009856 Merge branch 'l10n_main': Crowdin translation updates (#412)
Resolve ARB conflicts via per-key union: apply latest Crowdin
translations for shared keys while preserving newer app keys added on
main after the branch point. Drop hyphenated ARB duplicates
(app_ar-SA, app_es-ES, app_pt-PT, app_tr-TR, app_uk-UA) that break
Flutter gen-l10n; keep underscore filenames. Add Arabic (app_ar.arb)
and regenerate app_localizations.
2026-06-04 20:50:42 +07:00
Zarz Eleutherius 8419a75b04 New translations app_en.arb (Arabic)
[ci skip]
2026-06-04 20:24:31 +07:00
zarzet 5d474d6fe8 fix(l10n): correct crowdin language mapping
Map placeholders to the project's actual Crowdin language ids and drop the bogus bare keys (es, pt, zh) that aren't real Crowdin codes and broke crowdin-cli config validation. Add Arabic (ar) mapped to app_ar.arb so future syncs use underscore filenames instead of hyphenated ones (e.g. app_ar-SA.arb) that break Flutter gen-l10n.
2026-06-04 20:20:05 +07:00
Zarz Eleutherius e597505a1c New translations app_en.arb (French)
[ci skip]
2026-06-02 03:38:17 +07:00
github-actions[bot] 8675d263e7 chore: update AltStore source to v4.5.6 2026-06-01 18:23:57 +00:00
Zarz Eleutherius cfda124995 New translations app_en.arb (Hindi)
[ci skip]
2026-06-02 01:09:09 +07:00
Zarz Eleutherius 212f1cacca New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-06-02 01:09:07 +07:00
Zarz Eleutherius dd89de7cad New translations app_en.arb (Ukrainian)
[ci skip]
2026-06-02 01:09:05 +07:00
Zarz Eleutherius 8b4372dc7f New translations app_en.arb (Turkish)
[ci skip]
2026-06-02 01:09:03 +07:00
Zarz Eleutherius 2a25557632 New translations app_en.arb (Russian)
[ci skip]
2026-06-02 01:09:01 +07:00
Zarz Eleutherius 0cbb339948 New translations app_en.arb (Portuguese)
[ci skip]
2026-06-02 01:08:59 +07:00
Zarz Eleutherius 1496f51e30 New translations app_en.arb (Dutch)
[ci skip]
2026-06-02 01:08:58 +07:00
Zarz Eleutherius d1c5fe0605 New translations app_en.arb (Korean)
[ci skip]
2026-06-02 01:08:56 +07:00
Zarz Eleutherius 56786f60ff New translations app_en.arb (Japanese)
[ci skip]
2026-06-02 01:08:54 +07:00
Zarz Eleutherius af5d36f69f New translations app_en.arb (German)
[ci skip]
2026-06-02 01:08:52 +07:00
Zarz Eleutherius e40da71ef8 New translations app_en.arb (Arabic)
[ci skip]
2026-06-02 01:08:50 +07:00
Zarz Eleutherius 26b8bf422c New translations app_en.arb (Indonesian)
[ci skip]
2026-06-02 01:08:49 +07:00
Zarz Eleutherius 0a545706bd New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-02 01:08:47 +07:00
Zarz Eleutherius 9ebac610c7 New translations app_en.arb (Spanish)
[ci skip]
2026-06-02 01:08:45 +07:00
Zarz Eleutherius 5fc8a6af2a New translations app_en.arb (French)
[ci skip]
2026-06-02 01:08:43 +07:00
Zarz Eleutherius 1623f443bb New translations app_en.arb (Spanish)
[ci skip]
2026-05-31 09:12:29 +07:00
Zarz Eleutherius aa47bc4499 New translations app_en.arb (French)
[ci skip]
2026-05-28 18:50:01 +07:00
Zarz Eleutherius f461322842 New translations app_en.arb (French)
[ci skip]
2026-05-28 17:08:19 +07:00
Zarz Eleutherius cce05a0077 New translations app_en.arb (French)
[ci skip]
2026-05-28 16:08:05 +07:00
Zarz Eleutherius 98dc868f47 New translations app_en.arb (French)
[ci skip]
2026-05-28 14:30:31 +07:00
Zarz Eleutherius 821a41c10e New translations app_en.arb (French)
[ci skip]
2026-05-28 03:12:44 +07:00
Zarz Eleutherius 853ccd657a New translations app_en.arb (French)
[ci skip]
2026-05-28 01:57:58 +07:00
Zarz Eleutherius 680fc81db2 New translations app_en.arb (French)
[ci skip]
2026-05-27 23:40:18 +07:00
Zarz Eleutherius 36470eda24 New translations app_en.arb (French)
[ci skip]
2026-05-27 21:58:35 +07:00
Zarz Eleutherius a37dd6c8cb New translations app_en.arb (French)
[ci skip]
2026-05-27 04:52:32 +07:00
Zarz Eleutherius 588f742871 New translations app_en.arb (French)
[ci skip]
2026-05-27 03:45:18 +07:00
Zarz Eleutherius ff25a10e5b New translations app_en.arb (French)
[ci skip]
2026-05-27 02:33:22 +07:00
Zarz Eleutherius 499457f66a New translations app_en.arb (French)
[ci skip]
2026-05-26 23:49:07 +07:00
Zarz Eleutherius 6d15050009 New translations app_en.arb (French)
[ci skip]
2026-05-26 22:09:18 +07:00
Zarz Eleutherius 5ba30031c3 New translations app_en.arb (French)
[ci skip]
2026-05-26 05:23:50 +07:00
Zarz Eleutherius 82c0eef504 New translations app_en.arb (French)
[ci skip]
2026-05-26 04:27:43 +07:00
Zarz Eleutherius 616267e997 New translations app_en.arb (Arabic)
[ci skip]
2026-05-24 15:17:12 +07:00
Zarz Eleutherius 161b0c8c21 New translations app_en.arb (Arabic)
[ci skip]
2026-05-23 16:58:47 +07:00
Zarz Eleutherius facd185d6c New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-23 00:53:46 +07:00
Zarz Eleutherius 42858bf336 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-22 23:40:00 +07:00
Zarz Eleutherius 716be88caf New translations app_en.arb (Arabic)
[ci skip]
2026-05-22 16:14:08 +07:00
Zarz Eleutherius b296726a9d New translations app_en.arb (Spanish)
[ci skip]
2026-05-21 04:09:42 +07:00
Zarz Eleutherius 092f18d7a5 New translations app_en.arb (Spanish)
[ci skip]
2026-05-21 02:23:55 +07:00
Zarz Eleutherius f1ef33e319 New translations app_en.arb (Spanish)
[ci skip]
2026-05-21 01:12:56 +07:00
Zarz Eleutherius fc9bc95418 New translations app_en.arb (Indonesian)
[ci skip]
2026-05-19 01:44:44 +07:00
Zarz Eleutherius c61e64f332 New translations app_en.arb (Spanish)
[ci skip]
2026-05-18 00:54:25 +07:00
Zarz Eleutherius 70ebb8ef1a New translations app_en.arb (Spanish)
[ci skip]
2026-05-17 23:29:19 +07:00
Zarz Eleutherius a4c6a92478 New translations app_en.arb (Spanish)
[ci skip]
2026-05-17 22:18:42 +07:00
Zarz Eleutherius 76b453e535 New translations app_en.arb (Spanish)
[ci skip]
2026-05-17 20:59:16 +07:00
Zarz Eleutherius 19acdd87f5 New translations app_en.arb (Spanish)
[ci skip]
2026-05-17 19:59:25 +07:00
Zarz Eleutherius 492e1335ef New translations app_en.arb (German)
[ci skip]
2026-05-16 20:27:00 +07:00
Zarz Eleutherius 23cde7add3 New translations app_en.arb (Spanish)
[ci skip]
2026-05-16 20:26:59 +07:00
Zarz Eleutherius a20c28db25 New translations app_en.arb (German)
[ci skip]
2026-05-16 19:31:57 +07:00
Zarz Eleutherius f07d46c49e New translations app_en.arb (German)
[ci skip]
2026-05-16 18:21:28 +07:00
Zarz Eleutherius e9781a24a6 New translations app_en.arb (Turkish)
[ci skip]
2026-05-15 20:32:29 +07:00
Zarz Eleutherius 15be15ba58 New translations app_en.arb (Turkish)
[ci skip]
2026-05-15 18:57:35 +07:00
Zarz Eleutherius 8011d41e53 New translations app_en.arb (Arabic)
[ci skip]
2026-05-15 06:18:25 +07:00
Zarz Eleutherius 5412f23d26 New translations app_en.arb (Hindi)
[ci skip]
2026-05-15 06:18:23 +07:00
Zarz Eleutherius 0c39ff47f2 New translations app_en.arb (Indonesian)
[ci skip]
2026-05-15 06:18:21 +07:00
Zarz Eleutherius 537af905f6 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-05-15 06:18:20 +07:00
Zarz Eleutherius 6b4f70bde3 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-15 06:18:18 +07:00
Zarz Eleutherius be2b6d2c1f New translations app_en.arb (Ukrainian)
[ci skip]
2026-05-15 06:18:17 +07:00
Zarz Eleutherius 0c1a6d8f19 New translations app_en.arb (Turkish)
[ci skip]
2026-05-15 06:18:15 +07:00
Zarz Eleutherius 2821997260 New translations app_en.arb (Russian)
[ci skip]
2026-05-15 06:18:13 +07:00
Zarz Eleutherius 0546a33b10 New translations app_en.arb (Portuguese)
[ci skip]
2026-05-15 06:18:11 +07:00
Zarz Eleutherius deb98d8dfb New translations app_en.arb (Dutch)
[ci skip]
2026-05-15 06:18:09 +07:00
Zarz Eleutherius 72c658eda7 New translations app_en.arb (Korean)
[ci skip]
2026-05-15 06:18:07 +07:00
Zarz Eleutherius df17f10c8a New translations app_en.arb (Japanese)
[ci skip]
2026-05-15 06:18:05 +07:00
Zarz Eleutherius 9cacf2dc8e New translations app_en.arb (German)
[ci skip]
2026-05-15 06:18:04 +07:00
Zarz Eleutherius c7bc9f5b1c New translations app_en.arb (Spanish)
[ci skip]
2026-05-15 06:18:02 +07:00
Zarz Eleutherius 49ba8ae0d2 New translations app_en.arb (French)
[ci skip]
2026-05-15 06:18:00 +07:00
Zarz Eleutherius 3a62442ed0 New translations app_en.arb (Spanish)
[ci skip]
2026-05-15 01:05:23 +07:00
Zarz Eleutherius 3a1b92f9c4 New translations app_en.arb (Spanish)
[ci skip]
2026-05-14 23:24:51 +07:00
Zarz Eleutherius 30f97394ec New translations app_en.arb (French)
[ci skip]
2026-05-12 04:22:24 +07:00
Zarz Eleutherius 592308c1c6 New translations app_en.arb (French)
[ci skip]
2026-05-12 03:19:49 +07:00
Zarz Eleutherius 8bcfc63da0 New translations app_en.arb (French)
[ci skip]
2026-05-12 00:43:24 +07:00
Zarz Eleutherius a9cfff2692 New translations app_en.arb (French)
[ci skip]
2026-05-11 22:40:12 +07:00
Zarz Eleutherius 9e7ff56113 New translations app_en.arb (French)
[ci skip]
2026-05-11 18:52:31 +07:00
Zarz Eleutherius 9071143bbd New translations app_en.arb (French)
[ci skip]
2026-05-11 16:57:43 +07:00
Zarz Eleutherius 40770aff15 New translations app_en.arb (Turkish)
[ci skip]
2026-05-11 01:05:00 +07:00
Zarz Eleutherius 2bc5ef34ee New translations app_en.arb (Spanish)
[ci skip]
2026-05-10 06:34:38 +07:00
Zarz Eleutherius 6b9a3d95cd New translations app_en.arb (Spanish)
[ci skip]
2026-05-09 13:06:15 +07:00
Zarz Eleutherius 4fe51cef96 New translations app_en.arb (Spanish)
[ci skip]
2026-05-08 13:37:22 +07:00
Zarz Eleutherius 672ce024f8 New translations app_en.arb (French)
[ci skip]
2026-05-07 04:04:43 +07:00
Zarz Eleutherius 8224e93447 New translations app_en.arb (Russian)
[ci skip]
2026-05-07 02:40:48 +07:00
Zarz Eleutherius 1ba810fffb New translations app_en.arb (German)
[ci skip]
2026-05-07 02:40:46 +07:00
Zarz Eleutherius 1a725d0d31 New translations app_en.arb (French)
[ci skip]
2026-05-07 02:40:44 +07:00
Zarz Eleutherius 51c5b42a78 New translations app_en.arb (Arabic)
[ci skip]
2026-05-07 01:24:32 +07:00
Zarz Eleutherius 2908827018 New translations app_en.arb (German)
[ci skip]
2026-05-07 01:24:30 +07:00
Zarz Eleutherius b985cbf694 New translations app_en.arb (German)
[ci skip]
2026-05-06 23:33:52 +07:00
Zarz Eleutherius 1293d92896 New translations app_en.arb (Hindi)
[ci skip]
2026-05-06 22:15:45 +07:00
Zarz Eleutherius 705d41931d New translations app_en.arb (Indonesian)
[ci skip]
2026-05-06 22:15:43 +07:00
Zarz Eleutherius 29de69d323 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-05-06 22:15:41 +07:00
Zarz Eleutherius 28727d89f6 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-06 22:15:39 +07:00
Zarz Eleutherius 4704bcf52f New translations app_en.arb (Ukrainian)
[ci skip]
2026-05-06 22:15:37 +07:00
Zarz Eleutherius 13c148fb6c New translations app_en.arb (Turkish)
[ci skip]
2026-05-06 22:15:35 +07:00
Zarz Eleutherius e6079452f9 New translations app_en.arb (Russian)
[ci skip]
2026-05-06 22:15:33 +07:00
Zarz Eleutherius b68b7d5c9b New translations app_en.arb (Portuguese)
[ci skip]
2026-05-06 22:15:31 +07:00
Zarz Eleutherius 741fcdb4d9 New translations app_en.arb (Dutch)
[ci skip]
2026-05-06 22:15:30 +07:00
Zarz Eleutherius 642f8c5398 New translations app_en.arb (Korean)
[ci skip]
2026-05-06 22:15:28 +07:00
Zarz Eleutherius 1c15d5e7d3 New translations app_en.arb (Japanese)
[ci skip]
2026-05-06 22:15:26 +07:00
Zarz Eleutherius e71090338c New translations app_en.arb (German)
[ci skip]
2026-05-06 22:15:24 +07:00
Zarz Eleutherius 7c0feaaae0 New translations app_en.arb (Spanish)
[ci skip]
2026-05-06 22:15:22 +07:00
Zarz Eleutherius 5aa3ff4bb5 New translations app_en.arb (French)
[ci skip]
2026-05-06 22:15:20 +07:00
Zarz Eleutherius d4c83db428 New translations app_en.arb (Russian)
[ci skip]
2026-05-06 17:21:54 +07:00
Zarz Eleutherius 9f2d51fd4d New translations app_en.arb (Russian)
[ci skip]
2026-05-06 14:56:46 +07:00
Zarz Eleutherius 36137e8970 New translations app_en.arb (Russian)
[ci skip]
2026-05-06 01:29:54 +07:00
Zarz Eleutherius 823e56926f New translations app_en.arb (German)
[ci skip]
2026-05-06 00:16:56 +07:00
Zarz Eleutherius dd8a54dd43 New translations app_en.arb (German)
[ci skip]
2026-05-05 15:20:56 +07:00
Zarz Eleutherius 1ff33b96fa New translations app_en.arb (German)
[ci skip]
2026-05-05 13:21:43 +07:00
Zarz Eleutherius 4be9273768 New translations app_en.arb (Hindi)
[ci skip]
2026-05-03 01:39:32 +07:00
Zarz Eleutherius f458ac2162 New translations app_en.arb (Indonesian)
[ci skip]
2026-05-03 01:39:31 +07:00
Zarz Eleutherius b5ea2bb4c1 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-05-03 01:39:29 +07:00
Zarz Eleutherius 284d257921 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-03 01:39:28 +07:00
Zarz Eleutherius 30bf6b7f9a New translations app_en.arb (Ukrainian)
[ci skip]
2026-05-03 01:39:27 +07:00
Zarz Eleutherius 4941b6bd23 New translations app_en.arb (Turkish)
[ci skip]
2026-05-03 01:39:25 +07:00
Zarz Eleutherius 33d99817ec New translations app_en.arb (Russian)
[ci skip]
2026-05-03 01:39:24 +07:00
Zarz Eleutherius 37e1af50ad New translations app_en.arb (Portuguese)
[ci skip]
2026-05-03 01:39:22 +07:00
Zarz Eleutherius 8a6efb1303 New translations app_en.arb (Dutch)
[ci skip]
2026-05-03 01:39:21 +07:00
Zarz Eleutherius 7823b19b89 New translations app_en.arb (Korean)
[ci skip]
2026-05-03 01:39:19 +07:00
Zarz Eleutherius 2a9aa544a9 New translations app_en.arb (Japanese)
[ci skip]
2026-05-03 01:39:18 +07:00
Zarz Eleutherius f387c8ff85 New translations app_en.arb (German)
[ci skip]
2026-05-03 01:39:17 +07:00
Zarz Eleutherius 7e537aec0b New translations app_en.arb (Spanish)
[ci skip]
2026-05-03 01:39:15 +07:00
Zarz Eleutherius 66cd465565 New translations app_en.arb (French)
[ci skip]
2026-05-03 01:39:14 +07:00
Zarz Eleutherius 83afa40423 New translations app_en.arb (Hindi)
[ci skip]
2026-05-03 00:20:59 +07:00
Zarz Eleutherius 486e7eb101 New translations app_en.arb (Indonesian)
[ci skip]
2026-05-03 00:20:58 +07:00
Zarz Eleutherius 05eb9e60d3 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-05-03 00:20:56 +07:00
Zarz Eleutherius dde7095644 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-03 00:20:55 +07:00
Zarz Eleutherius f1e9a2915d New translations app_en.arb (Ukrainian)
[ci skip]
2026-05-03 00:20:53 +07:00
Zarz Eleutherius ae3495d373 New translations app_en.arb (Turkish)
[ci skip]
2026-05-03 00:20:51 +07:00
Zarz Eleutherius 6fb2c1b688 New translations app_en.arb (Russian)
[ci skip]
2026-05-03 00:20:50 +07:00
Zarz Eleutherius 1526c558e7 New translations app_en.arb (Portuguese)
[ci skip]
2026-05-03 00:20:49 +07:00
Zarz Eleutherius 324e0f053b New translations app_en.arb (Dutch)
[ci skip]
2026-05-03 00:20:47 +07:00
Zarz Eleutherius 25cb33c78e New translations app_en.arb (Korean)
[ci skip]
2026-05-03 00:20:46 +07:00
Zarz Eleutherius 942b6d9569 New translations app_en.arb (Japanese)
[ci skip]
2026-05-03 00:20:44 +07:00
Zarz Eleutherius cd46c79383 New translations app_en.arb (German)
[ci skip]
2026-05-03 00:20:43 +07:00
Zarz Eleutherius 0bdcdcc229 New translations app_en.arb (Spanish)
[ci skip]
2026-05-03 00:20:42 +07:00
Zarz Eleutherius 1a5863a7fb New translations app_en.arb (French)
[ci skip]
2026-05-03 00:20:40 +07:00
Zarz Eleutherius 701015ad55 New translations app_en.arb (Spanish)
[ci skip]
2026-05-01 04:51:14 +07:00
Zarz Eleutherius 63cfac626a New translations app_en.arb (French) 2026-04-28 05:12:53 +07:00
Zarz Eleutherius e6c5a21bfc New translations app_en.arb (French) 2026-04-28 04:13:54 +07:00
Zarz Eleutherius 2d80739141 New translations app_en.arb (Spanish) 2026-04-26 01:44:55 +07:00
Zarz Eleutherius 6494102e15 New translations app_en.arb (French) 2026-04-24 15:12:52 +07:00
Zarz Eleutherius 0e6aa2efd9 New translations app_en.arb (French) 2026-04-24 05:23:14 +07:00
Zarz Eleutherius f412c216c5 New translations app_en.arb (French) 2026-04-24 00:51:45 +07:00
Zarz Eleutherius af15e3d914 New translations app_en.arb (French) 2026-04-23 23:54:53 +07:00
Zarz Eleutherius b00ff3f3f0 New translations app_en.arb (German) 2026-04-23 21:06:22 +07:00
Zarz Eleutherius 1607e6830e New translations app_en.arb (French) 2026-04-23 19:03:21 +07:00
Zarz Eleutherius 817e0bf2bd New translations app_en.arb (French) 2026-04-23 16:51:44 +07:00
Zarz Eleutherius 0f12fbce6a New translations app_en.arb (French) 2026-04-23 14:54:33 +07:00
Zarz Eleutherius 953a09d75f New translations app_en.arb (Ukrainian) 2026-04-22 01:52:19 +07:00
Zarz Eleutherius 5098989614 New translations app_en.arb (Russian) 2026-04-20 18:24:22 +07:00
Zarz Eleutherius 5828bcffdd New translations app_en.arb (Korean) 2026-04-20 18:24:21 +07:00
Zarz Eleutherius ae87a7d58f New translations app_en.arb (Korean) 2026-04-20 16:22:00 +07:00
Zarz Eleutherius 32ab78a213 New translations app_en.arb (Russian) 2026-04-19 21:20:37 +07:00
Zarz Eleutherius 69583d172c New translations app_en.arb (Russian) 2026-04-19 19:52:56 +07:00
Zarz Eleutherius 38367c1c77 New translations app_en.arb (Russian) 2026-04-19 18:31:58 +07:00
Zarz Eleutherius 2f6bf91a1c New translations app_en.arb (German) 2026-04-19 02:58:18 +07:00
Zarz Eleutherius 60b062bbaf New translations app_en.arb (German) 2026-04-19 02:01:11 +07:00
Zarz Eleutherius 30e8b604a9 New translations app_en.arb (Ukrainian) 2026-04-18 23:47:31 +07:00
Zarz Eleutherius 7c3ab92e17 New translations app_en.arb (Turkish) 2026-04-18 23:47:29 +07:00
Zarz Eleutherius 37b101c70f New translations app_en.arb (Portuguese) 2026-04-18 23:47:28 +07:00
Zarz Eleutherius b7be46e6ae New translations app_en.arb (Spanish) 2026-04-18 23:47:25 +07:00
Zarz Eleutherius bf1f79866b New translations app_en.arb (Hindi) 2026-04-18 23:35:11 +07:00
Zarz Eleutherius a6460426a2 New translations app_en.arb (Indonesian) 2026-04-18 23:35:10 +07:00
Zarz Eleutherius 304ba14d20 New translations app_en.arb (Chinese Traditional) 2026-04-18 23:35:09 +07:00
Zarz Eleutherius db47233d92 New translations app_en.arb (Chinese Simplified) 2026-04-18 23:35:08 +07:00
Zarz Eleutherius 74eeb98be8 New translations app_en.arb (Russian) 2026-04-18 23:35:06 +07:00
Zarz Eleutherius 331da0f897 New translations app_en.arb (Dutch) 2026-04-18 23:35:04 +07:00
Zarz Eleutherius 73964ee648 New translations app_en.arb (Korean) 2026-04-18 23:35:03 +07:00
Zarz Eleutherius a5e8402141 New translations app_en.arb (Japanese) 2026-04-18 23:35:02 +07:00
Zarz Eleutherius c5e7fcf29b New translations app_en.arb (German) 2026-04-18 23:35:01 +07:00
Zarz Eleutherius d3cf6d30a7 New translations app_en.arb (French) 2026-04-18 23:34:59 +07:00
Zarz Eleutherius 74e14f7a43 New translations app_en.arb (Hindi) 2026-04-18 22:24:11 +07:00
Zarz Eleutherius 02e347adb0 New translations app_en.arb (Indonesian) 2026-04-18 22:24:10 +07:00
Zarz Eleutherius 56983cb85b New translations app_en.arb (Chinese Traditional) 2026-04-18 22:24:09 +07:00
Zarz Eleutherius 7917c656b0 New translations app_en.arb (Chinese Simplified) 2026-04-18 22:24:08 +07:00
Zarz Eleutherius fc34c1e548 New translations app_en.arb (Ukrainian) 2026-04-18 22:24:07 +07:00
Zarz Eleutherius f32aeaa0ff New translations app_en.arb (Turkish) 2026-04-18 22:24:06 +07:00
Zarz Eleutherius 86097a932c New translations app_en.arb (Russian) 2026-04-18 22:24:05 +07:00
Zarz Eleutherius f74f24c41f New translations app_en.arb (Portuguese) 2026-04-18 22:24:04 +07:00
Zarz Eleutherius 8e99e7b07e New translations app_en.arb (Dutch) 2026-04-18 22:24:03 +07:00
Zarz Eleutherius e06aab6e87 New translations app_en.arb (Korean) 2026-04-18 22:24:01 +07:00
Zarz Eleutherius a81e56fb26 New translations app_en.arb (Japanese) 2026-04-18 22:24:00 +07:00
Zarz Eleutherius 9a09b119c5 New translations app_en.arb (German) 2026-04-18 22:23:59 +07:00
Zarz Eleutherius 4b28ca1055 New translations app_en.arb (Spanish) 2026-04-18 22:23:58 +07:00
Zarz Eleutherius d684d9f8d1 New translations app_en.arb (French) 2026-04-18 22:23:57 +07:00
177 changed files with 61348 additions and 18117 deletions
+14 -7
View File
@@ -66,7 +66,7 @@ jobs:
uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "17"
java-version: "25"
- name: Setup Go
uses: actions/setup-go@v6
@@ -257,6 +257,15 @@ jobs:
- name: Get Flutter dependencies
run: flutter pub get
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
run: |
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
while IFS= read -r -d '' f; do
perl -pi -e 's/\r$//' "$f"
chmod +x "$f"
echo "Normalized line endings: $f"
done
- name: Generate app icons
run: dart run flutter_launcher_icons
@@ -379,8 +388,6 @@ jobs:
### Installation
**Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
![arm64](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=flat-square&logo=android&label=arm64&color=3DDC84) ![arm32](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=flat-square&logo=android&label=arm32&color=3DDC84) ![iOS](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=flat-square&logo=apple&label=iOS&color=0078D6)
FOOTER
echo "Release body:"
@@ -390,7 +397,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
body_path: /tmp/release_body.txt
files: ./release/*
draft: false
@@ -556,7 +563,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM64_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
fi
# Upload arm32 APK to channel
@@ -565,7 +572,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM32_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm32"
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
fi
# Upload iOS IPA to channel
@@ -575,7 +582,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${IOS_IPA}" \
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
fi
echo "Telegram notification sent!"
+4 -1
View File
@@ -60,12 +60,15 @@ ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
# Extension folder
extension/
extension/*
extension/v2/
extension/v2/**
# Agent instructions
AGENTS.md
# Temp/misc
.tmp/
nul
NUL
network_requests.txt
+3 -6
View File
@@ -59,7 +59,7 @@ Extensions let the community add new music sources and features without waiting
## Related Projects
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
@@ -80,7 +80,7 @@ Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository
<summary><b>Why is my download failing with "Song not found"?</b></summary>
<br>
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
The track may not be available from your enabled providers. Try enabling more providers under **Settings > Extensions > Provider Priority**, or install additional download extensions from the Store.
</details>
@@ -88,10 +88,7 @@ The track may not be available on the streaming services. Try enabling more prov
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
<br>
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
- **Tidal** up to 24-bit/192kHz
- **Qobuz** up to 24-bit/192kHz
- **Deezer** up to 16-bit/44.1kHz
Quality depends on what's available from the source and the installed download extension. Check each extension's quality options and service notes in the app.
</details>
+7 -5
View File
@@ -17,7 +17,7 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "com.zarz.spotiflac"
compileSdk = flutter.compileSdkVersion
compileSdk = 37
ndkVersion = flutter.ndkVersion
buildFeatures {
@@ -26,13 +26,13 @@ android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
}
}
@@ -50,7 +50,7 @@ android {
defaultConfig {
applicationId = "com.zarz.spotiflac"
minSdk = flutter.minSdkVersion
targetSdk = 36
targetSdk = 37
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
@@ -62,6 +62,8 @@ android {
buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
ndk {
debugSymbolLevel = "FULL"
}
+27
View File
@@ -100,6 +100,12 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="spotify-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="session-grant" />
</intent-filter>
</activity>
<!-- Download Service -->
@@ -108,6 +114,23 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
android:enabled="true">
<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">
@@ -124,6 +147,10 @@
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- FileProvider for APK installation -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -1,6 +1,7 @@
package com.zarz.spotiflac
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
@@ -17,6 +18,7 @@ import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import com.ryanheise.audioservice.AudioServicePlugin
import gobackend.Gobackend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -36,6 +38,10 @@ import java.security.MessageDigest
import java.util.Locale
class MainActivity: FlutterFragmentActivity() {
override fun provideFlutterEngine(context: Context): FlutterEngine {
return AudioServicePlugin.getFlutterEngine(context)
}
private val CHANNEL = "com.zarz.spotiflac/backend"
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
"com.zarz.spotiflac/download_progress_stream"
@@ -47,6 +53,8 @@ class MainActivity: FlutterFragmentActivity() {
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var backendChannel: MethodChannel? = null
private val pendingSessionGrantEvents = mutableListOf<Map<String, Any>>()
private var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any()
private val safDirLock = Any()
@@ -148,8 +156,15 @@ class MainActivity: FlutterFragmentActivity() {
"mali-t7",
"powervr sgx",
"powervr ge8320",
"vivante",
"gc1000",
"gc2000",
"gc4000",
"gc5000",
"gc7000",
"gc8000",
"gc820",
"gc880",
)
private val PROBLEMATIC_CHIPSETS = listOf(
@@ -163,6 +178,15 @@ class MainActivity: FlutterFragmentActivity() {
"apq8084",
)
// Sony Walkman / audio players report MANUFACTURER "SonyAudio" (distinct
// from Xperia phones, which use "Sony"). They ship legacy Vivante GPUs
// whose drivers crash in glLinkProgram with Impeller shaders, and the GL
// renderer string is unavailable when shell args are built, so match on
// the manufacturer instead.
private val PROBLEMATIC_MANUFACTURERS = listOf(
"sonyaudio",
)
private val PROBLEMATIC_MODELS = listOf(
"sm-t220",
"sm-t225",
@@ -173,6 +197,14 @@ class MainActivity: FlutterFragmentActivity() {
val board = Build.BOARD.lowercase(Locale.ROOT)
val model = Build.MODEL.lowercase(Locale.ROOT)
val device = Build.DEVICE.lowercase(Locale.ROOT)
val manufacturer = Build.MANUFACTURER.lowercase(Locale.ROOT)
for (problematicManufacturer in PROBLEMATIC_MANUFACTURERS) {
if (manufacturer.contains(problematicManufacturer)) {
android.util.Log.i("SpotiFLAC", "Matched problematic manufacturer: $problematicManufacturer")
return true
}
}
for (problematicModel in PROBLEMATIC_MODELS) {
if (model.contains(problematicModel) || device.contains(problematicModel)) {
@@ -307,6 +339,8 @@ class MainActivity: FlutterFragmentActivity() {
".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg"
".flac" -> "audio/flac"
".wav" -> "audio/wav"
".aiff", ".aif", ".aifc" -> "audio/aiff"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream"
}
@@ -791,6 +825,8 @@ class MainActivity: FlutterFragmentActivity() {
"audio/mpeg" -> ".mp3"
"audio/ogg" -> ".opus"
"audio/flac" -> ".flac"
"audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav"
"audio/aiff", "audio/x-aiff" -> ".aiff"
else -> ""
}
}
@@ -1113,6 +1149,16 @@ class MainActivity: FlutterFragmentActivity() {
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
)
// Audio file extensions that the local library scanner accepts. Must stay in
// sync with supportedAudioFormats in go_backend/library_scan.go so that every
// format the Go engine can read (FLAC, M4A/MP4/AAC, MP3, Opus/OGG, APE/WV/MPC,
// WAV, AIFF) is also enumerated here during the SAF folder walk. (.cue is
// handled separately.)
private val libraryScanAudioExtensions = setOf(
".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg",
".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif"
)
private fun getSafChildFileLookup(
dir: DocumentFile,
cache: MutableMap<String, Map<String, DocumentFile>>,
@@ -1182,7 +1228,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
val supportedAudioExt = libraryScanAudioExtensions
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
@@ -1482,7 +1528,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
val supportedAudioExt = libraryScanAudioExtensions
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
@@ -2035,14 +2081,22 @@ class MainActivity: FlutterFragmentActivity() {
}
val host = (uri.host ?: "").lowercase(Locale.US)
val path = (uri.path ?: "").lowercase(Locale.US)
val isSessionGrant = host == "session-grant"
val isCallback =
host == "callback" ||
isSessionGrant ||
host == "callback" ||
host == "spotify-callback" ||
path.contains("callback")
if (!isCallback) {
return
}
val code = uri.getQueryParameter("code")?.trim().orEmpty()
val code = (
if (isSessionGrant) {
uri.getQueryParameter("grant") ?: uri.getQueryParameter("code")
} else {
uri.getQueryParameter("code")
}
)?.trim().orEmpty()
if (code.isEmpty()) {
return
}
@@ -2054,15 +2108,43 @@ class MainActivity: FlutterFragmentActivity() {
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")
val json = if (isSessionGrant) {
Gobackend.setExtensionSessionGrantByID(extId, code)
Gobackend.invokeExtensionActionJSON(extId, "completeGrant")
} else {
Gobackend.setExtensionAuthCodeByID(extId, code)
Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
}
android.util.Log.i("SpotiFLAC", "Extension callback complete for $extId: $json")
if (isSessionGrant) {
withContext(Dispatchers.Main) {
notifySessionGrantCompleted(extId, true)
}
}
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
android.util.Log.w("SpotiFLAC", "Extension callback failed: ${e.message}")
if (isSessionGrant) {
withContext(Dispatchers.Main) {
notifySessionGrantCompleted(extId, false)
}
}
}
}
}
private fun notifySessionGrantCompleted(extensionId: String, success: Boolean) {
val payload = mapOf(
"extension_id" to extensionId,
"success" to success,
)
val channel = backendChannel
if (channel == null) {
pendingSessionGrantEvents.add(payload)
return
}
channel.invokeMethod("extensionSessionGrantCompleted", payload)
}
override fun onDestroy() {
try {
Gobackend.cleanupExtensions()
@@ -2126,7 +2208,17 @@ class MainActivity: FlutterFragmentActivity() {
},
)
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
val channel = MethodChannel(messenger, CHANNEL)
backendChannel = channel
if (pendingSessionGrantEvents.isNotEmpty()) {
val events = pendingSessionGrantEvents.toList()
pendingSessionGrantEvents.clear()
for (event in events) {
channel.invokeMethod("extensionSessionGrantCompleted", event)
}
}
channel.setMethodCallHandler { call, result ->
scope.launch {
try {
when (call.method) {
@@ -2211,6 +2303,13 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"setAllowPrivateNetwork" -> {
val allowed = call.argument<Boolean>("allowed") ?: false
withContext(Dispatchers.IO) {
Gobackend.setAllowPrivateNetwork(allowed)
}
result.success(null)
}
"checkDuplicate" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
val isrc = call.argument<String>("isrc") ?: ""
@@ -2629,6 +2728,46 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"writeM4AFreeformTags" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
val response = withContext(Dispatchers.IO) {
try {
Gobackend.writeM4AFreeformTags(filePath, metadataJson)
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "writeM4AFreeformTags failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"ensureAC4Config" -> {
val filePath = call.argument<String>("file_path") ?: ""
val sourcePath = call.argument<String>("source_path") ?: ""
val response = withContext(Dispatchers.IO) {
try {
Gobackend.ensureAC4Config(filePath, sourcePath)
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "ensureAC4Config failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"writeAC4Metadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
val coverPath = call.argument<String>("cover_path") ?: ""
val response = withContext(Dispatchers.IO) {
try {
Gobackend.writeAC4Metadata(filePath, metadataJson, coverPath)
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "writeAC4Metadata failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"writeTempToSaf" -> {
val tempPath = call.argument<String>("temp_path") ?: ""
val safUri = call.argument<String>("saf_uri") ?: ""
@@ -334,7 +334,6 @@ object NativeDownloadFinalizer {
}
private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) {
// Kept as a narrow hook for future richer progress snapshots.
}
private fun cleanupFailedFinalizationOutput(
@@ -422,16 +421,19 @@ object NativeDownloadFinalizer {
try {
for (candidate in decryptionKeyCandidates(key)) {
checkCancelled(shouldCancel)
val attempts = mutableListOf<Pair<String, Boolean>>()
attempts.add(outputPath to (preferredExt == ".flac"))
val attempts = mutableListOf<Triple<String, Boolean, Boolean>>()
attempts.add(Triple(outputPath, preferredExt == ".flac", false))
if (preferredExt == ".flac") {
attempts.add(buildOutputPath(localInput, ".m4a") to false)
attempts.add(Triple(buildOutputPath(localInput, ".m4a"), false, false))
}
if (preferredExt == ".flac" || preferredExt == ".m4a") {
attempts.add(buildOutputPath(localInput, ".mp4") to false)
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, false))
}
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4):
// keeps the .mp4 filename but stores the codec params.
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, true))
for ((candidateOutput, mapAudioOnly) in attempts) {
for ((candidateOutput, mapAudioOnly, forceMov) in attempts) {
try {
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
// Force the flac muxer when the target extension is
@@ -439,7 +441,11 @@ object NativeDownloadFinalizer {
// stream layout, producing FLAC-in-MP4 under a .flac
// filename which downstream native FLAC tag writers
// cannot read.
val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else ""
val muxerOverride = when {
forceMov -> "-f mov "
candidateOutput.lowercase(Locale.ROOT).endsWith(".flac") -> "-f flac "
else -> ""
}
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
val result = runFFmpeg(command, shouldCancel)
lastOutput = result.second
@@ -1159,18 +1165,28 @@ object NativeDownloadFinalizer {
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
var adoptedTemp = false
var originalDeleted = false
try {
val command = if (isM4a && coverFile != null) {
fun buildEmbedCommand(forceMov: Boolean): String {
return if (isM4a && coverFile != null) {
"-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " +
"-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " +
"-disposition:v:0 attached_pic " +
"-metadata:s:v ${q("title=Album cover")} " +
"-metadata:s:v ${q("comment=Cover (front)")} " +
"$metadataArgs -f mp4 ${q(temp.absolutePath)} -y"
"$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
} else {
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags${q(temp.absolutePath)} -y"
val movFlag = if (forceMov) "-f mov " else ""
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y"
}
}
try {
var result = runFFmpeg(buildEmbedCommand(false))
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4).
if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) {
temp.delete()
result = runFFmpeg(buildEmbedCommand(true))
}
val result = runFFmpeg(command)
if (result.first && temp.exists()) {
if (inputFile.delete()) {
originalDeleted = true
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
+3 -3
View File
@@ -11,8 +11,8 @@ subprojects {
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}
// Enable multidex for all subprojects
@@ -27,7 +27,7 @@ subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
}
}
}
+4 -4
View File
@@ -7,12 +7,12 @@
"name": "SpotiFLAC Mobile",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.5.5",
"versionDate": "2026-05-14",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.5/SpotiFLAC-v4.5.5-ios-unsigned.ipa",
"version": "4.7.1",
"versionDate": "2026-07-01",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa",
"localizedDescription": "SpotiFLAC Mobile is 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": 34915749
"size": 37455821
}
]
}
+4 -5
View File
@@ -3,9 +3,11 @@ files:
translation: /lib/l10n/arb/app_%locale%.arb
languages_mapping:
locale:
# Short codes for single-variant languages
# Keys MUST be the project's Crowdin language ids; values are the
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
# gen-l10n parses them — hyphenated filenames break gen-l10n).
ar: ar
de: de
es: es
es-ES: es_ES
fr: fr
hi: hi
@@ -13,12 +15,9 @@ files:
ja: ja
ko: ko
nl: nl
pt: pt
pt-PT: pt_PT
ru: ru
tr: tr
uk: uk
zh: zh
# Full codes for Chinese variants
zh-CN: zh_CN
zh-TW: zh_TW
+322
View File
@@ -0,0 +1,322 @@
package gobackend
import (
"encoding/binary"
"fmt"
"os"
)
// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer.
type mp4Box struct {
offset int64
size int64
hdr int64
typ string
}
func (b mp4Box) body() int64 { return b.offset + b.hdr }
func (b mp4Box) end() int64 { return b.offset + b.size }
func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
n := int64(len(data))
if pos < 0 || pos+8 > n {
return mp4Box{}, false
}
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
typ := string(data[pos+4 : pos+8])
hdr := int64(8)
if size == 1 {
if pos+16 > n {
return mp4Box{}, false
}
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
hdr = 16
} else if size == 0 {
size = n - pos
}
if size < hdr || pos+size > n {
return mp4Box{}, false
}
return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true
}
func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) {
pos := start
for pos+8 <= end {
b, ok := readMP4Box(data, pos)
if !ok {
return mp4Box{}, false
}
if b.typ == typ {
return b, true
}
pos = b.end()
}
return mp4Box{}, false
}
func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) {
pos := start
for pos+8 <= end {
b, ok := readMP4Box(data, pos)
if !ok {
return
}
if b.typ == typ && !fn(b) {
return
}
pos = b.end()
}
}
// findBoxBySignature scans [start,end) for a box of the given type, matching the
// 4-byte type tag and validating the preceding size field. Used to locate dac4
// which may be nested inside an encrypted (enca) sample entry.
func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) {
if len(typ) != 4 {
return mp4Box{}, false
}
for i := start; i+8 <= end; i++ {
if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] {
if b, ok := readMP4Box(data, i); ok && b.typ == typ {
return b, true
}
}
}
return mp4Box{}, false
}
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
// entry header (from the box body start) before child boxes begin. ok is false
// for malformed/truncated entries whose declared header is not fully present.
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
base := entry.body()
if base+10 > entry.end() {
return 0, false
}
version := binary.BigEndian.Uint16(data[base+8 : base+10])
hdrLen = 8 + 20
switch version {
case 1:
hdrLen += 16
case 2:
hdrLen += 36
}
if base+hdrLen > entry.end() {
return 0, false
}
return hdrLen, true
}
type ac4Location struct {
chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow)
entry mp4Box // the ac-4 sample entry
}
func locateAC4Entry(data []byte) (ac4Location, bool) {
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
if !ok {
return ac4Location{}, false
}
var found ac4Location
var ok2 bool
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
if !ok {
return true
}
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
if !ok {
return true
}
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
if !ok {
return true
}
stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd")
if !ok {
return true
}
entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4")
if !ok {
return true
}
found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry}
ok2 = true
return false
})
return found, ok2
}
func growBoxSize(data []byte, b mp4Box, delta int64) {
if b.hdr == 16 {
binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta))
} else {
binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta))
}
}
// shiftChunkOffsets adds delta to every stco/co64 entry that references a file
// offset at or beyond insertPos, keeping sample pointers valid after bytes are
// inserted into moov.
func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
if !ok {
return true
}
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
if !ok {
return true
}
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
if !ok {
return true
}
if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok {
base := stco.body() + 4
if base+4 <= stco.end() {
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
p := base + 4
for i := int64(0); i < count && p+4 <= stco.end(); i++ {
v := int64(binary.BigEndian.Uint32(data[p : p+4]))
if v >= insertPos {
binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta))
}
p += 4
}
}
}
if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok {
base := co64.body() + 4
if base+4 <= co64.end() {
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
p := base + 4
for i := int64(0); i < count && p+8 <= co64.end(); i++ {
v := int64(binary.BigEndian.Uint64(data[p : p+8]))
if v >= insertPos {
binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta))
}
p += 8
}
}
}
return true
})
}
// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov
// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a
// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry.
// Windows Media Foundation (and other strict parsers) reject the QuickTime
// flavor for AC-4 even when dac4 is present.
func normalizeQuickTimeAudioToMP4(data []byte) []byte {
if ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp"); ok {
if ftyp.body()+4 <= int64(len(data)) {
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
}
for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 {
if string(data[p:p+4]) == "qt " {
copy(data[p:p+4], []byte("isom"))
}
}
}
loc, ok := locateAC4Entry(data)
if !ok {
return data
}
entry := loc.entry
verPos := entry.body() + 8
if verPos+2 > entry.end() {
return data
}
if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 {
return data // already v0 (or v2, left untouched)
}
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
extStart := entry.body() + 8 + 20
extEnd := extStart + 16
if extEnd > entry.end() {
return data
}
delta := int64(-16)
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
shiftChunkOffsets(data, loc.chain[0], extStart, delta)
for _, b := range loc.chain {
growBoxSize(data, b, delta)
}
growBoxSize(data, entry, delta)
out := make([]byte, 0, len(data)-16)
out = append(out, data[:extStart]...)
out = append(out, data[extEnd:]...)
return out
}
// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and
// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4
// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The
// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext
// moov still carries it). No-op when the file has no AC-4 track.
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
dst, err := os.ReadFile(decryptedPath)
if err != nil {
return err
}
if _, ok := locateAC4Entry(dst); !ok {
return nil // not an AC-4 file; nothing to do
}
dst = normalizeQuickTimeAudioToMP4(dst)
loc, ok := locateAC4Entry(dst)
if !ok {
return nil
}
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
if !ok {
return fmt.Errorf("malformed ac-4 sample entry")
}
childStart := loc.entry.body() + hdrLen
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
// Already has dac4; still persist any normalization changes.
return os.WriteFile(decryptedPath, dst, 0o644)
}
src, err := os.ReadFile(sourcePath)
if err != nil {
return err
}
srcMoov, ok := findChildMP4(src, 0, int64(len(src)), "moov")
if !ok {
return fmt.Errorf("source has no moov")
}
dac4Box, ok := findBoxBySignature(src, srcMoov.body(), srcMoov.end(), "dac4")
if !ok {
return fmt.Errorf("dac4 not found in source")
}
dac4 := append([]byte{}, src[dac4Box.offset:dac4Box.end()]...)
insertPos := childStart
delta := int64(len(dac4))
shiftChunkOffsets(dst, loc.chain[0], insertPos, delta)
for _, b := range loc.chain {
growBoxSize(dst, b, delta)
}
growBoxSize(dst, loc.entry, delta)
out := make([]byte, 0, len(dst)+len(dac4))
out = append(out, dst[:insertPos]...)
out = append(out, dac4...)
out = append(out, dst[insertPos:]...)
return os.WriteFile(decryptedPath, out, 0o644)
}
+76
View File
@@ -0,0 +1,76 @@
package gobackend
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func mp4TestBox(typ string, body []byte) []byte {
out := make([]byte, 8+len(body))
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
copy(out[4:8], typ)
copy(out[8:], body)
return out
}
func mp4TestAC4Tree(entryBody []byte) []byte {
entry := mp4TestBox("ac-4", entryBody)
stsdBody := append([]byte{
0, 0, 0, 0, // version/flags
0, 0, 0, 1, // entry_count
}, entry...)
stsd := mp4TestBox("stsd", stsdBody)
stbl := mp4TestBox("stbl", stsd)
minf := mp4TestBox("minf", stbl)
mdia := mp4TestBox("mdia", minf)
trak := mp4TestBox("trak", mdia)
moov := mp4TestBox("moov", trak)
return moov
}
func shortAC4SampleEntryBody(version uint16) []byte {
body := make([]byte, 10)
binary.BigEndian.PutUint16(body[8:10], version)
return body
}
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
defer func() {
if r := recover(); r != nil {
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
}
}()
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
if !bytes.Equal(got, input) {
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
}
}
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
dir := t.TempDir()
decryptedPath := filepath.Join(dir, "decrypted.mp4")
sourcePath := filepath.Join(dir, "source.mp4")
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
t.Fatal(err)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
}
}()
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
t.Fatal("expected malformed AC-4 sample entry error")
}
}
+182
View File
@@ -0,0 +1,182 @@
package gobackend
import (
"encoding/binary"
"encoding/json"
"os"
"strconv"
"strings"
)
// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric
// fields are strings because they arrive as a JSON-encoded map of strings.
type ac4Metadata struct {
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
AlbumArtist string `json:"albumArtist"`
Date string `json:"date"`
Genre string `json:"genre"`
Composer string `json:"composer"`
TrackNumber string `json:"trackNumber"`
TotalTracks string `json:"totalTracks"`
DiscNumber string `json:"discNumber"`
TotalDiscs string `json:"totalDiscs"`
ISRC string `json:"isrc"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Lyrics string `json:"lyrics"`
}
func atoiSafe(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0
}
return n
}
func itunesTextTag(atomType, value string) []byte {
data := make([]byte, 8+len(value))
binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8
copy(data[8:], []byte(value))
return buildM4AAtom(atomType, buildM4AAtom("data", data))
}
func itunesNumberPairTag(atomType string, number, total int) []byte {
payload := make([]byte, 8)
binary.BigEndian.PutUint16(payload[2:4], uint16(number))
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
data := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary
copy(data[8:], payload)
return buildM4AAtom(atomType, buildM4AAtom("data", data))
}
func itunesCoverTag(image []byte) []byte {
typeCode := uint32(13) // JPEG
if len(image) >= 8 &&
image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 {
typeCode = 14 // PNG
}
data := make([]byte, 8+len(image))
binary.BigEndian.PutUint32(data[0:4], typeCode)
copy(data[8:], image)
return buildM4AAtom("covr", buildM4AAtom("data", data))
}
func itunesMetadataHandler() []byte {
payload := make([]byte, 0, 25)
payload = append(payload, 0, 0, 0, 0) // version + flags
payload = append(payload, 0, 0, 0, 0) // pre_defined
payload = append(payload, []byte("mdir")...) // handler type
payload = append(payload, []byte("appl")...) // reserved[0]
payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2]
payload = append(payload, 0) // empty name
return buildM4AAtom("hdlr", payload)
}
// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata.
func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
ilst := make([]byte, 0, 256)
add := func(atomType, value string) {
if strings.TrimSpace(value) != "" {
ilst = append(ilst, itunesTextTag(atomType, value)...)
}
}
add("\xa9nam", md.Title)
add("\xa9ART", md.Artist)
add("\xa9alb", md.Album)
add("aART", md.AlbumArtist)
add("\xa9day", md.Date)
add("\xa9gen", md.Genre)
add("\xa9wrt", md.Composer)
if tn := atoiSafe(md.TrackNumber); tn > 0 {
ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...)
}
if dn := atoiSafe(md.DiscNumber); dn > 0 {
ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...)
}
if strings.TrimSpace(md.ISRC) != "" {
ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...)
}
if strings.TrimSpace(md.Label) != "" {
ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...)
}
if strings.TrimSpace(md.Copyright) != "" {
add("cprt", md.Copyright)
}
if strings.TrimSpace(md.Lyrics) != "" {
add("\xa9lyr", md.Lyrics)
}
if len(cover) > 0 {
ilst = append(ilst, itunesCoverTag(cover)...)
}
ilstBox := buildM4AAtom("ilst", ilst)
metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...)
metaPayload = append(metaPayload, ilstBox...)
meta := buildM4AAtom("meta", metaPayload)
return buildM4AAtom("udta", meta)
}
// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in
// the moov of an MP4 buffer and returns the rewritten bytes.
func writeMP4iTunesMetadata(data []byte, md ac4Metadata, cover []byte) []byte {
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
if !ok {
return data
}
newUdta := buildITunesUdta(md, cover)
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
delta := int64(len(newUdta)) - udta.size
shiftChunkOffsets(data, moov, udta.offset, delta)
growBoxSize(data, moov, delta)
out := make([]byte, 0, len(data)+len(newUdta))
out = append(out, data[:udta.offset]...)
out = append(out, newUdta...)
out = append(out, data[udta.end():]...)
return out
}
delta := int64(len(newUdta))
insertPos := moov.end()
shiftChunkOffsets(data, moov, insertPos, delta)
growBoxSize(data, moov, delta)
out := make([]byte, 0, len(data)+len(newUdta))
out = append(out, data[:insertPos]...)
out = append(out, newUdta...)
out = append(out, data[insertPos:]...)
return out
}
// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns
// true when the file was an AC-4 track and metadata was written; false when the
// file is not AC-4 (the caller should fall back to its normal metadata path).
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
data, err := os.ReadFile(decryptedPath)
if err != nil {
return false, err
}
if _, ok := locateAC4Entry(data); !ok {
return false, nil
}
var md ac4Metadata
if strings.TrimSpace(metadataJSON) != "" {
_ = json.Unmarshal([]byte(metadataJSON), &md)
}
var cover []byte
if strings.TrimSpace(coverPath) != "" {
if b, err := os.ReadFile(coverPath); err == nil {
cover = b
}
}
out := writeMP4iTunesMetadata(data, md, cover)
if err := os.WriteFile(decryptedPath, out, 0o644); err != nil {
return false, err
}
return true, nil
}
-1
View File
@@ -314,7 +314,6 @@ func marshalAPETag(tag *APETag) ([]byte, error) {
footerFlags := uint32(1 << 31)
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
// Final layout: header + items + footer
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
result = append(result, header...)
result = append(result, itemsData...)
+3
View File
@@ -1624,6 +1624,9 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
}
return data, mimeType, nil
case ".wav", ".aiff", ".aif", ".aifc":
return extractWAVAIFFCover(filePath)
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
}
+1 -1
View File
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
}
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, baseName+ext)
if _, err := os.Stat(candidate); err == nil {
+23 -12
View File
@@ -3,6 +3,7 @@ package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -783,7 +784,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
// not include this field. Albums whose track count is already known (non-zero)
// are skipped.
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
// Find albums that need track counts
type indexedID struct {
idx int
albumID string
@@ -1267,16 +1267,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
}
lastErr = err
errStr := err.Error()
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "status 5") ||
strings.Contains(errStr, "status 429")
if !isRetryable {
if !isDeezerRetryableError(err) {
return err
}
@@ -1286,6 +1277,26 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
}
type deezerAPIError struct {
StatusCode int
Body string
}
func (e *deezerAPIError) Error() string {
return fmt.Sprintf("deezer API returned status %d: %s", e.StatusCode, e.Body)
}
func isDeezerRetryableError(err error) bool {
if isConnectivityFailure(err) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
var apiErr *deezerAPIError
if errors.As(err, &apiErr) {
return apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError
}
return false
}
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
@@ -1306,7 +1317,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body)}
}
return json.Unmarshal(body, dst)
+249 -17
View File
@@ -283,6 +283,7 @@ type DownloadRequest struct {
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
TidalHighFormat string `json:"tidal_high_format,omitempty"`
TrackNumber int `json:"track_number"`
PlaylistPosition int `json:"playlist_position,omitempty"`
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"`
TotalDiscs int `json:"total_discs,omitempty"`
@@ -310,6 +311,7 @@ type DownloadResponse struct {
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"`
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
@@ -1160,6 +1162,8 @@ func ReadFileMetadata(filePath string) (string, error) {
isApe := strings.HasSuffix(lower, ".ape")
isWv := strings.HasSuffix(lower, ".wv")
isMpc := strings.HasSuffix(lower, ".mpc")
isWav := strings.HasSuffix(lower, ".wav")
isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
result := map[string]interface{}{
"title": "",
@@ -1376,7 +1380,6 @@ func ReadFileMetadata(filePath string) (string, error) {
} else if isApe || isWv || isMpc {
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
result["audio_codec"] = result["format"]
// APE, WavPack, Musepack: read APEv2 tags
apeTag, apeErr := ReadAPETags(filePath)
if apeErr == nil && apeTag != nil {
meta := APETagToAudioMetadata(apeTag)
@@ -1406,6 +1409,51 @@ func ReadFileMetadata(filePath string) (string, error) {
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
}
}
} else if isWav || isAiff {
var meta *AudioMetadata
var quality *WAVQuality
var qualityErr error
if isAiff {
result["format"] = "aiff"
result["audio_codec"] = "pcm"
meta, _ = ReadAIFFTags(filePath)
quality, qualityErr = GetAIFFQuality(filePath)
} else {
result["format"] = "wav"
result["audio_codec"] = "pcm"
meta, _ = ReadWAVTags(filePath)
quality, qualityErr = GetWAVQuality(filePath)
}
if meta != nil {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["total_tracks"] = meta.TotalTracks
result["disc_number"] = meta.DiscNumber
result["total_discs"] = meta.TotalDiscs
result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre
result["label"] = meta.Label
result["copyright"] = meta.Copyright
result["composer"] = meta.Composer
result["comment"] = meta.Comment
result["replaygain_track_gain"] = meta.ReplayGainTrackGain
result["replaygain_track_peak"] = meta.ReplayGainTrackPeak
result["replaygain_album_gain"] = meta.ReplayGainAlbumGain
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
}
if qualityErr == nil && quality != nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("unsupported file format: %s", filePath)
}
@@ -1463,6 +1511,48 @@ func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefi
return string(jsonBytes), nil
}
// WriteM4AFreeformTags writes ISRC and label into an M4A/MP4 file as iTunes
// freeform atoms. FFmpeg's MP4 muxer ignores these keys, so they must be
// written natively after the FFmpeg metadata pass for the values to persist.
// Only keys present in the JSON are touched; an empty value clears the tag.
func WriteM4AFreeformTags(filePath, metadataJSON string) (string, error) {
var fields map[string]string
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
return "", fmt.Errorf("invalid metadata JSON: %w", err)
}
if err := EditM4AFreeformText(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A freeform tags: %w", err)
}
resp := map[string]any{"success": true, "method": "native_m4a_freeform"}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO
// MP4 and injects the dac4 configuration box copied from sourcePath. No-op when
// the file is not AC-4.
func EnsureAC4Config(filePath, sourcePath string) (string, error) {
if err := EnsureAC4ConfigBox(filePath, sourcePath); err != nil {
return "", fmt.Errorf("failed to finalize AC-4 container: %w", err)
}
return `{"success":true}`, nil
}
// WriteAC4Metadata writes iTunes-style metadata into an AC-4 MP4. The JSON
// "handled" field reports whether the file was AC-4 (true) so the caller can
// skip the FFmpeg metadata pass that would re-wrap it as QuickTime.
func WriteAC4Metadata(filePath, metadataJSON, coverPath string) (string, error) {
handled, err := WriteAC4MetadataIfApplicable(filePath, metadataJSON, coverPath)
if err != nil {
return "", fmt.Errorf("failed to write AC-4 metadata: %w", err)
}
resp := map[string]any{"success": true, "handled": handled}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg.
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
var fields map[string]string
@@ -1474,6 +1564,8 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
isFlac := strings.HasSuffix(lower, ".flac")
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
isWavFile := strings.HasSuffix(lower, ".wav")
isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
coverPath := strings.TrimSpace(fields["cover_path"])
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
@@ -1502,7 +1594,24 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
// APE/WV/MPC: write APEv2 tags natively
// WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively.
if isWavFile {
if err := WriteWAVTags(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write WAV metadata: %w", err)
}
resp := map[string]any{"success": true, "method": "native_wav"}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
if isAiffFile {
if err := WriteAIFFTags(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write AIFF metadata: %w", err)
}
resp := map[string]any{"success": true, "method": "native_aiff"}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
if isApeFile {
trackNum := 0
totalTracks := 0
@@ -1751,9 +1860,13 @@ func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath st
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
source := extractLyricsSourceFromLRC(lyrics)
if source == "" {
source = "Embedded"
}
result := map[string]interface{}{
"lyrics": lyrics,
"source": "Embedded",
"source": source,
"sync_type": "EMBEDDED",
"instrumental": false,
}
@@ -1964,6 +2077,7 @@ func normalizeExtensionTrackMetadataMap(
"duration_ms": track.DurationMS,
"images": coverURL,
"cover_url": coverURL,
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": trackNum,
"total_tracks": track.TotalTracks,
@@ -1992,9 +2106,12 @@ func normalizeExtensionAlbumInfoMap(album *ExtAlbumMetadata) map[string]interfac
"artist_id": album.ArtistID,
"images": album.CoverURL,
"cover_url": album.CoverURL,
"header_image": album.HeaderImage,
"header_video": album.HeaderVideo,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
"album_type": album.AlbumType,
"audio_traits": album.AudioTraits,
"provider_id": album.ProviderID,
}
}
@@ -2079,11 +2196,13 @@ func getExtensionProviderMetadataResponse(
return map[string]interface{}{
"playlist_info": map[string]interface{}{
"id": playlist.ID,
"name": playlist.Name,
"images": playlist.CoverURL,
"cover_url": playlist.CoverURL,
"provider_id": playlist.ProviderID,
"id": playlist.ID,
"name": playlist.Name,
"images": playlist.CoverURL,
"cover_url": playlist.CoverURL,
"header_image": playlist.HeaderImage,
"header_video": playlist.HeaderVideo,
"provider_id": playlist.ProviderID,
"owner": map[string]interface{}{
"name": playlist.Artists,
"images": playlist.CoverURL,
@@ -2112,6 +2231,7 @@ func getExtensionProviderMetadataResponse(
"images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL),
"cover_url": artist.ImageURL,
"header_image": artist.HeaderImage,
"header_video": artist.HeaderVideo,
"provider_id": artist.ProviderID,
},
"albums": albums,
@@ -2161,6 +2281,16 @@ func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (strin
switch strings.ToLower(trimmedProviderID) {
case "deezer":
if response, ok, err := getEnabledExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID); ok || err != nil {
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(response)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
return GetDeezerMetadata(resourceType, resourceID)
default:
response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID)
@@ -2176,6 +2306,19 @@ func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (strin
}
}
func getEnabledExtensionProviderMetadataResponse(providerID, resourceType, resourceID string) (map[string]interface{}, bool, error) {
manager := getExtensionManager()
ext, err := manager.GetExtension(providerID)
if err != nil || ext == nil || !ext.Enabled || !ext.Manifest.IsMetadataProvider() {
return nil, false, nil
}
response, err := getExtensionProviderMetadataResponse(providerID, resourceType, resourceID)
if err != nil {
return nil, true, err
}
return response, true, nil
}
func GetDeezerExtendedMetadata(trackID string) (string, error) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
@@ -2399,8 +2542,19 @@ func classifyDownloadErrorType(msg string) string {
return "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") {
return "cancelled"
} else if strings.Contains(lowerMsg, "verify_required") ||
strings.Contains(lowerMsg, "verification_required") ||
strings.Contains(lowerMsg, "verification required") ||
strings.Contains(lowerMsg, "needs verification") ||
strings.Contains(lowerMsg, "session is not authenticated") ||
strings.Contains(lowerMsg, "signed session is not authenticated") ||
strings.Contains(lowerMsg, "unauthorized") ||
strings.Contains(lowerMsg, "precondition required") ||
messageHasHTTPStatusCode(lowerMsg, "401") ||
messageHasHTTPStatusCode(lowerMsg, "428") {
return "verification_required"
} else if strings.Contains(lowerMsg, "rate limit") ||
strings.Contains(lowerMsg, "429") ||
messageHasHTTPStatusCode(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") {
return "rate_limit"
} else if strings.Contains(lowerMsg, "permission") ||
@@ -2425,6 +2579,15 @@ func classifyDownloadErrorType(msg string) string {
return "unknown"
}
func messageHasHTTPStatusCode(lowerMsg, code string) bool {
return strings.Contains(lowerMsg, "http "+code) ||
strings.Contains(lowerMsg, "http status "+code) ||
strings.Contains(lowerMsg, "status "+code) ||
strings.Contains(lowerMsg, code+" for ") ||
strings.Contains(lowerMsg, code+":") ||
strings.Contains(lowerMsg, code+";")
}
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
@@ -2577,8 +2740,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
// When search_online is true, search for metadata from internet using the
// configured metadata-provider priority.
if req.SearchOnline {
found := false
@@ -2747,7 +2908,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
if isFlac {
// Native Go FLAC metadata embedding.
// Only populate Metadata fields for selected update groups; empty/zero
// values cause EmbedMetadata's setComment() to skip those tags,
// preserving whatever is already in the file.
@@ -3127,7 +3287,7 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
}
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
req := GetPendingAuthRequest(extensionID)
req := ensureExtensionPendingAuthRequest(extensionID)
if req == nil {
return "", nil
}
@@ -3146,10 +3306,48 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
return string(jsonBytes), nil
}
func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
extensionID = strings.TrimSpace(extensionID)
if extensionID == "" {
return nil
}
if req := GetPendingAuthRequest(extensionID); req != nil {
return req
}
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil {
return nil
}
if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil {
return nil
}
config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return nil
}
if record, err := ext.runtime.loadSignedSession(config); err == nil {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
_ = ext.runtime.saveSignedSession(config, record)
}
ext.runtime.startSignedSessionVerification(config, "pending-auth-request")
return GetPendingAuthRequest(extensionID)
}
func SetExtensionAuthCodeByID(extensionID, authCode string) {
SetExtensionAuthCode(extensionID, authCode)
}
func SetExtensionSessionGrantByID(extensionID, grant string) {
setPendingSignedSessionGrant(extensionID, grant)
}
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
var expiresAt time.Time
if expiresIn > 0 {
@@ -3316,6 +3514,7 @@ func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optio
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
@@ -3381,6 +3580,8 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"extension_id": extensionID,
"name": result.Name,
"cover_url": result.CoverURL,
"header_image": result.HeaderImage,
"header_video": result.HeaderVideo,
}
if result.Track != nil {
@@ -3392,6 +3593,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"album_artist": result.Track.AlbumArtist,
"duration_ms": result.Track.DurationMS,
"images": result.Track.ResolvedCoverURL(),
"preview_url": result.Track.PreviewURL,
"release_date": result.Track.ReleaseDate,
"track_number": result.Track.TrackNumber,
"total_tracks": result.Track.TotalTracks,
@@ -3414,6 +3616,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
@@ -3435,6 +3638,9 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"name": result.Album.Name,
"artists": result.Album.Artists,
"cover_url": result.Album.CoverURL,
"header_image": result.Album.HeaderImage,
"header_video": result.Album.HeaderVideo,
"audio_traits": result.Album.AudioTraits,
"release_date": result.Album.ReleaseDate,
"total_tracks": result.Album.TotalTracks,
"album_type": result.Album.AlbumType,
@@ -3448,6 +3654,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"name": result.Artist.Name,
"image_url": result.Artist.ImageURL,
"header_image": result.Artist.HeaderImage,
"header_video": result.Artist.HeaderVideo,
"listeners": result.Artist.Listeners,
"provider_id": result.Artist.ProviderID,
}
@@ -3507,6 +3714,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
@@ -3742,13 +3950,29 @@ func GetStoreCategoriesJSON() (string, error) {
return string(jsonBytes), nil
}
func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
func storeExtensionPackageSuffix(downloadURL string) string {
rawPath := downloadURL
if parsed, err := url.Parse(downloadURL); err == nil {
rawPath = parsed.Path
}
lowerPath := strings.ToLower(rawPath)
if strings.HasSuffix(lowerPath, ".sflx") {
return ".sflx"
}
if strings.HasSuffix(lowerPath, ".spotiflac-ext") {
return ".spotiflac-ext"
}
return ".spotiflac-ext"
}
func buildStoreExtensionDestPath(destDir, extensionID, downloadURL string) (string, error) {
if strings.TrimSpace(extensionID) == "" {
return "", fmt.Errorf("invalid extension id")
}
safeExtensionID := sanitizeFilename(extensionID)
return filepath.Join(destDir, safeExtensionID+".spotiflac-ext"), nil
return filepath.Join(destDir, safeExtensionID+storeExtensionPackageSuffix(downloadURL)), nil
}
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
@@ -3757,7 +3981,12 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
return "", fmt.Errorf("extension store not initialized")
}
destPath, err := buildStoreExtensionDestPath(destDir, extensionID)
ext, err := store.findExtension(extensionID)
if err != nil {
return "", err
}
destPath, err := buildStoreExtensionDestPath(destDir, extensionID, ext.getDownloadURL())
if err != nil {
return "", err
}
@@ -3822,9 +4051,12 @@ func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, ti
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
return extension.%s();
}
if (typeof %s === 'function') {
return %s();
}
return null;
})()
`, functionName, functionName)
`, functionName, functionName, functionName, functionName)
jsStartedAt := time.Now()
result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout)
+55 -2
View File
@@ -31,6 +31,44 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
}
}
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
cases := []string{
"HTTP 401 for /tickets",
"HTTP status 428: precondition required",
"Verification required",
}
for _, tc := range cases {
if got := classifyDownloadErrorType(tc); got != "verification_required" {
t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got)
}
}
}
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
dir := t.TempDir()
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
t.Fatalf("InitExtensionSystem: %v", err)
}
CleanupExtensions()
defer CleanupExtensions()
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
ext.ID = "deezer"
ext.Manifest.Name = "deezer"
manager := getExtensionManager()
manager.mu.Lock()
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
manager.mu.Unlock()
jsonText, err := GetProviderMetadataJSON("deezer", "album", "201")
if err != nil {
t.Fatalf("GetProviderMetadataJSON deezer album: %v", err)
}
if !strings.Contains(jsonText, "album-track") {
t.Fatalf("expected enabled deezer extension metadata, got %s", jsonText)
}
}
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
dir := t.TempDir()
dataDir := filepath.Join(dir, "data")
@@ -390,10 +428,25 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
}
if dest, err := buildStoreExtensionDestPath(dir, "coverage/ext"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
if dest, err := buildStoreExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.spotiflac-ext",
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
}
if _, err := buildStoreExtensionDestPath(dir, " "); err == nil {
if dest, err := buildStoreExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.sflx",
); err != nil || !strings.HasSuffix(dest, ".sflx") {
t.Fatalf("buildStoreExtensionDestPath sflx = %q/%v", dest, err)
}
if _, err := buildStoreExtensionDestPath(
dir,
" ",
"https://registry.example.com/coverage.sflx",
); err == nil {
t.Fatal("expected invalid extension id")
}
if err := ClearStoreCacheJSON(); err != nil {
+86 -17
View File
@@ -15,7 +15,9 @@ import (
const (
extensionHealthDefaultTimeout = 4 * time.Second
extensionHealthMaxBodyBytes = 64 * 1024
extensionHealthDefaultCache = 60 * time.Second
extensionHealthDefaultCache = 10 * time.Minute
extensionHealthMinCache = 60 * time.Second
extensionHealthUnknownCache = 2 * time.Minute
)
type ExtensionHealthResult struct {
@@ -58,6 +60,7 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
}
result := CheckExtensionHealth(ext)
cacheExtensionHealthResult(ext, result)
bytes, err := json.Marshal(result)
if err != nil {
return "", err
@@ -85,16 +88,31 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
extensionHealthCacheMu.Unlock()
result := CheckExtensionHealth(ext)
cacheExtensionHealthResult(ext, result)
return result
}
func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResult) {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return
}
cacheKey := strings.TrimSpace(ext.ID)
if cacheKey == "" {
return
}
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
if result.Status == "unknown" && ttl > extensionHealthUnknownCache {
ttl = extensionHealthUnknownCache
}
extensionHealthCacheMu.Lock()
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
result: result,
expiresAt: now.Add(ttl),
expiresAt: time.Now().Add(ttl),
}
extensionHealthCacheMu.Unlock()
return result
}
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
@@ -149,6 +167,9 @@ func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
continue
}
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
if checkTTL < extensionHealthMinCache {
checkTTL = extensionHealthMinCache
}
if checkTTL < ttl {
ttl = checkTTL
}
@@ -226,7 +247,11 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
resp, err := NewMetadataHTTPClient(timeout).Do(req)
result.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
result.Status = "offline"
if isTransientExtensionHealthError(err) {
result.Status = "unknown"
} else {
result.Status = "offline"
}
result.Error = err.Error()
return result
}
@@ -262,6 +287,10 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
return result
}
func isTransientExtensionHealthError(err error) bool {
return isTransientNetworkError(err) || isConnectivityFailure(err)
}
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
if len(strings.TrimSpace(string(body))) == 0 {
return "online", ""
@@ -287,6 +316,9 @@ func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string
case "degraded", "partial", "warning", "warn":
return "degraded", rawStatus
case "down", "offline", "error", "failed", "fail", "unhealthy":
if isTransientHealthStatusMessage(string(body)) {
return "unknown", rawStatus
}
return "offline", rawStatus
default:
return "online", rawStatus
@@ -327,42 +359,53 @@ func classifyExtensionHealthService(payload map[string]interface{}, serviceKey s
rawStatus, hasStatus := service["status"]
okValue, hasOK := service["ok"].(bool)
joinedMessage := strings.Join(messageParts, ": ")
transient := isTransientHealthStatusMessage(detail) ||
isTransientHealthStatusMessage(errText) ||
isTransientHealthStatusMessage(label)
if statusCode, ok := healthNumber(rawStatus); ok {
if statusCode >= 200 && statusCode < 300 {
return "online", strings.Join(messageParts, ": "), true
return "online", joinedMessage, true
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return "degraded", strings.Join(messageParts, ": "), true
return "degraded", joinedMessage, true
}
if statusCode == http.StatusInternalServerError && hasOK && okValue {
return "online", strings.Join(messageParts, ": "), true
return "online", joinedMessage, true
}
return "offline", strings.Join(messageParts, ": "), true
if transient || isTransientHealthStatusCode(statusCode) {
return "unknown", joinedMessage, true
}
return "offline", joinedMessage, true
}
if isExtensionHealthAuthRequired(detail) {
return "degraded", strings.Join(messageParts, ": "), true
return "degraded", joinedMessage, true
}
if transient {
return "unknown", joinedMessage, true
}
if hasOK {
if okValue {
return "online", strings.Join(messageParts, ": "), true
return "online", joinedMessage, true
}
return "offline", strings.Join(messageParts, ": "), true
return "offline", joinedMessage, true
}
if !hasStatus {
return "unknown", strings.Join(messageParts, ": "), true
return "unknown", joinedMessage, true
}
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
switch statusString {
case "ok", "up", "online", "healthy", "operational":
return "online", strings.Join(messageParts, ": "), true
return "online", joinedMessage, true
case "degraded", "partial", "warning", "warn":
return "degraded", strings.Join(messageParts, ": "), true
return "degraded", joinedMessage, true
case "down", "offline", "error", "failed", "fail", "unhealthy":
return "offline", strings.Join(messageParts, ": "), true
return "offline", joinedMessage, true
default:
return "unknown", strings.Join(messageParts, ": "), true
return "unknown", joinedMessage, true
}
}
@@ -375,6 +418,32 @@ func isExtensionHealthAuthRequired(detail string) bool {
}
}
func isTransientHealthStatusMessage(text string) bool {
t := strings.ToLower(strings.TrimSpace(text))
if t == "" {
return false
}
return strings.Contains(t, "context deadline exceeded") ||
strings.Contains(t, "deadline exceeded") ||
strings.Contains(t, "timeout") ||
strings.Contains(t, "timed out") ||
strings.Contains(t, "temporarily unavailable") ||
strings.Contains(t, "try again")
}
func isTransientHealthStatusCode(code int) bool {
switch code {
case http.StatusRequestTimeout,
http.StatusTooManyRequests,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return false
}
}
func healthNumber(value interface{}) (int, bool) {
switch v := value.(type) {
case float64:
@@ -1,8 +1,10 @@
package gobackend
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"testing"
@@ -27,6 +29,12 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
if !isExtensionHealthAuthRequired(" unauthorized ") {
t.Fatal("expected auth required")
}
if !isTransientExtensionHealthError(context.DeadlineExceeded) || !isTransientExtensionHealthError(&net.DNSError{IsTimeout: true}) {
t.Fatal("expected timeout health errors to be transient")
}
if !isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) {
t.Fatal("expected health transport lookup errors to be indeterminate")
}
if result := CheckExtensionHealth(nil); result.Status != "offline" {
t.Fatalf("nil health = %#v", result)
+85 -39
View File
@@ -44,18 +44,24 @@ func compareVersions(v1, v2 string) int {
return 0
}
func isExtensionPackagePath(filePath string) bool {
lowerPath := strings.ToLower(filePath)
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
}
type loadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *extensionRuntime
initialized bool
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *extensionRuntime
indexProgram *goja.Program
initialized bool
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
}
func getExtensionInitSettings(extensionID string) map[string]interface{} {
@@ -118,7 +124,11 @@ func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
}
type extensionManager struct {
mu sync.RWMutex
mu sync.RWMutex
// mutationMu serializes install/upgrade/remove (heavy FS + goja VM
// teardown/reload), which are not safe to run concurrently. Acquired before
// m.mu; "*Locked" helpers assume it is held.
mutationMu sync.Mutex
extensions map[string]*loadedExtension
extensionsDir string
dataDir string
@@ -156,8 +166,14 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) 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")
m.mutationMu.Lock()
defer m.mutationMu.Unlock()
return m.loadExtensionFromFileLocked(filePath)
}
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
}
zipReader, err := zip.OpenReader(filePath)
@@ -212,7 +228,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
if exists {
versionCompare := compareVersions(manifest.Version, existingVersion)
if versionCompare > 0 {
return m.UpgradeExtension(filePath)
return m.upgradeExtensionLocked(filePath)
} else if versionCompare == 0 {
return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion)
} else {
@@ -296,6 +312,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
func initializeVMLocked(ext *loadedExtension) error {
ext.VM = nil
ext.runtime = nil
ext.indexProgram = nil
ext.initialized = false
vm := goja.New()
ext.VM = vm
@@ -305,6 +322,11 @@ func initializeVMLocked(ext *loadedExtension) error {
if err != nil {
return fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return fmt.Errorf("failed to compile extension code: %w", err)
}
ext.indexProgram = indexProgram
runtime := newExtensionRuntime(ext)
ext.runtime = runtime
@@ -331,7 +353,7 @@ func initializeVMLocked(ext *loadedExtension) error {
return goja.Undefined()
})
_, err = vm.RunString(string(jsCode))
_, err = vm.RunProgram(indexProgram)
if err != nil {
return fmt.Errorf("failed to execute extension code: %w", err)
}
@@ -346,10 +368,17 @@ func initializeVMLocked(ext *loadedExtension) error {
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
vm := goja.New()
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
indexProgram := ext.indexProgram
if indexProgram == nil {
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
}
}
runtime := &extensionRuntime{
@@ -392,7 +421,7 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
return goja.Undefined()
})
if _, err := vm.RunString(string(jsCode)); err != nil {
if _, err := vm.RunProgram(indexProgram); err != nil {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
}
@@ -663,7 +692,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
loaded = append(loaded, ext.ID)
}
}
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
} else if isExtensionPackagePath(entry.Name()) {
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
if err != nil {
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
@@ -736,6 +765,9 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
}
func (m *extensionManager) RemoveExtension(extensionID string) error {
m.mutationMu.Lock()
defer m.mutationMu.Unlock()
ext, err := m.GetExtension(extensionID)
if err != nil {
return err
@@ -756,8 +788,14 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
// Only allows upgrades (new version > current version), not downgrades
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")
m.mutationMu.Lock()
defer m.mutationMu.Unlock()
return m.upgradeExtensionLocked(filePath)
}
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
}
zipReader, err := zip.OpenReader(filePath)
@@ -905,8 +943,8 @@ type ExtensionUpgradeInfo struct {
}
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")
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
}
zipReader, err := zip.OpenReader(filePath)
@@ -1151,14 +1189,16 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
// 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.
actionNameLiteral := strconv.Quote(actionName)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
return { success: true, pending: true, message: 'Action started' };
}
(function() {
var actionName = %s;
function runAction(fn) {
try {
var result = fn();
if (result && typeof result.then === 'function') {
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) {
@@ -1173,13 +1213,19 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
}
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
} catch (e) {
return { success: false, error: e.toString() };
}
}
}
return { success: false, error: 'Action function not found: %s' };
})()
`, actionName, actionName, actionName)
if (typeof extension !== 'undefined' && extension && typeof extension[actionName] === 'function') {
return runAction(function() { return extension[actionName](); });
}
if (actionName === 'completeGrant' && typeof session !== 'undefined' && session && typeof session.completeGrant === 'function') {
return runAction(function() { return session.completeGrant(); });
}
return { success: false, error: 'Action function not found: ' + actionName };
})()
`, actionNameLiteral)
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
if err != nil {
+63 -22
View File
@@ -3,6 +3,7 @@ package gobackend
import (
"encoding/json"
"fmt"
"net/url"
"strings"
)
@@ -113,28 +114,49 @@ type ExtensionHealthCheck struct {
Required bool `json:"required,omitempty"`
}
type SignedSessionEndpoints struct {
Bootstrap string `json:"bootstrap,omitempty"`
Challenge string `json:"challenge,omitempty"`
Exchange string `json:"exchange,omitempty"`
Refresh string `json:"refresh,omitempty"`
}
type SignedSessionConfig struct {
Namespace string `json:"namespace"`
BaseURL string `json:"baseUrl"`
AppVersion string `json:"appVersion,omitempty"`
Platform string `json:"platform,omitempty"`
CallbackURL string `json:"callbackUrl,omitempty"`
SchemeLabel string `json:"schemeLabel,omitempty"`
HeaderPrefix string `json:"headerPrefix,omitempty"`
TimeWindowSeconds int `json:"timeWindowSeconds,omitempty"`
Endpoints SignedSessionEndpoints `json:"endpoints,omitempty"`
}
type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipLyrics bool `json:"skipLyrics,omitempty"`
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipLyrics bool `json:"skipLyrics,omitempty"`
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
type ManifestValidationError struct {
@@ -200,7 +222,6 @@ func (m *ExtensionManifest) Validate() error {
}
}
// Select type requires options
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].options", i),
@@ -238,6 +259,26 @@ func (m *ExtensionManifest) Validate() error {
}
}
if m.SignedSession != nil {
if strings.TrimSpace(m.SignedSession.Namespace) == "" {
return &ManifestValidationError{Field: "signedSession.namespace", Message: "namespace is required"}
}
baseURL := strings.TrimSpace(m.SignedSession.BaseURL)
if baseURL == "" {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is required"}
}
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl must use https"}
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Hostname() == "" {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is invalid"}
}
if !m.IsDomainAllowed(parsed.Hostname()) {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl host must be listed in permissions.network"}
}
}
return nil
}
+423 -107
View File
@@ -29,6 +29,7 @@ type ExtTrackMetadata struct {
ExternalURL string `json:"external_urls,omitempty"`
DurationMS int `json:"duration_ms"`
CoverURL string `json:"cover_url,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
Images string `json:"images,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
@@ -68,9 +69,12 @@ type ExtAlbumMetadata struct {
Artists string `json:"artists"`
ArtistID string `json:"artist_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"`
HeaderVideo string `json:"header_video,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TotalTracks int `json:"total_tracks"`
AlbumType string `json:"album_type,omitempty"`
AudioTraits []string `json:"audio_traits,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks"`
ProviderID string `json:"provider_id"`
}
@@ -80,6 +84,7 @@ type ExtArtistMetadata struct {
Name string `json:"name"`
ImageURL string `json:"image_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"`
HeaderVideo string `json:"header_video,omitempty"`
Listeners int `json:"listeners,omitempty"`
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
@@ -473,6 +478,18 @@ func shouldAbortCancelledFallback(itemID string, err error) bool {
return itemID != "" && isDownloadCancelled(itemID)
}
func normalizeExtensionDownloadErrorType(errorType, message string) string {
normalized := strings.TrimSpace(errorType)
classified := classifyDownloadErrorType(message)
if classified != "" && classified != "unknown" {
switch strings.ToLower(normalized) {
case "", "unknown", "runtime_error", "api_error", "download_error", "extension_error":
return classified
}
}
return normalized
}
type DownloadDecryptionInfo struct {
Strategy string `json:"strategy,omitempty"`
Key string `json:"key,omitempty"`
@@ -483,14 +500,15 @@ type DownloadDecryptionInfo struct {
}
type ExtDownloadResult struct {
Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
@@ -724,6 +742,32 @@ func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map
return result
}
func gojaObjectStringSlice(obj *goja.Object, keys ...string) []string {
value := gojaObjectValue(obj, keys...)
if gojaValueIsEmpty(value) {
return nil
}
exported, ok := value.Export().([]interface{})
if !ok || len(exported) == 0 {
return nil
}
result := make([]string, 0, len(exported))
for _, item := range exported {
str, ok := item.(string)
if !ok {
continue
}
str = strings.TrimSpace(str)
if str != "" {
result = append(result, str)
}
}
if len(result) == 0 {
return nil
}
return result
}
func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) {
if gojaValueIsEmpty(value) {
return 0, nil
@@ -754,6 +798,7 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
PreviewURL: gojaObjectString(obj, "preview_url", "previewUrl"),
Images: gojaObjectString(obj, "images"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
@@ -820,12 +865,147 @@ func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetad
Artists: gojaObjectString(obj, "artists"),
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"),
Tracks: tracks,
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
}, nil
}.withTrackFallbacks(), nil
}
// withTrackFallbacks fills the album-level artist and release date from the
// album's tracks when the extension did not provide them at the album level.
// This is a generic mechanism so any extension benefits, without per-extension
// special-casing in the app.
func (a ExtAlbumMetadata) withTrackFallbacks() ExtAlbumMetadata {
if strings.TrimSpace(a.Artists) == "" {
a.Artists = albumArtistFromTracks(a.Tracks)
}
if strings.TrimSpace(a.ReleaseDate) == "" {
a.ReleaseDate = albumReleaseDateFromTracks(a.Tracks)
}
if len(a.AudioTraits) == 0 {
a.AudioTraits = albumAudioTraitsFromTracks(a.Tracks)
}
return a
}
// albumArtistFromTracks prefers an explicit per-track album artist, then falls
// back to the most common track artist across the album.
func albumArtistFromTracks(tracks []ExtTrackMetadata) string {
for _, t := range tracks {
if s := strings.TrimSpace(t.AlbumArtist); s != "" {
return s
}
}
counts := map[string]int{}
order := []string{}
for _, t := range tracks {
artist := strings.TrimSpace(t.Artists)
if artist == "" {
continue
}
if _, ok := counts[artist]; !ok {
order = append(order, artist)
}
counts[artist]++
}
best := ""
bestCount := 0
for _, artist := range order {
if counts[artist] > bestCount {
best = artist
bestCount = counts[artist]
}
}
return best
}
// albumReleaseDateFromTracks returns the first non-empty track release date.
func albumReleaseDateFromTracks(tracks []ExtTrackMetadata) string {
for _, t := range tracks {
if s := strings.TrimSpace(t.ReleaseDate); s != "" {
return s
}
}
return ""
}
// albumAudioTraitsFromTracks derives album-level audio badges (Dolby Atmos,
// Hi-Res Lossless, Lossless) from the per-track audio quality/mode fields that
// extensions like Tidal and Qobuz already provide. Tokens match what the album
// header understands ("dolby_atmos", "hi_res_lossless", "lossless").
func albumAudioTraitsFromTracks(tracks []ExtTrackMetadata) []string {
atmos := false
hiRes := false
lossless := false
for _, t := range tracks {
modes := strings.ToUpper(t.AudioModes)
quality := strings.ToUpper(t.AudioQuality)
if strings.Contains(modes, "ATMOS") || strings.Contains(quality, "ATMOS") {
atmos = true
}
if strings.Contains(quality, "HI_RES") ||
strings.Contains(quality, "HIRES") ||
strings.Contains(quality, "MASTER") ||
strings.Contains(quality, "MQA") {
hiRes = true
}
if strings.Contains(quality, "LOSSLESS") ||
strings.Contains(quality, "FLAC") {
lossless = true
}
if bd, sr := parseBitDepthSampleRate(quality); bd > 0 {
if bd > 16 || sr > 48 {
hiRes = true
} else {
lossless = true
}
}
}
traits := []string{}
if atmos {
traits = append(traits, "dolby_atmos")
}
if hiRes {
traits = append(traits, "hi_res_lossless")
} else if lossless {
traits = append(traits, "lossless")
}
return traits
}
// parseBitDepthSampleRate extracts a bit depth and sample rate (in kHz) from
// labels such as "24bit/96kHz", "16bit/44.1kHz" or "24bit".
func parseBitDepthSampleRate(quality string) (int, float64) {
lower := strings.ToLower(quality)
bitDepth := 0
sampleRate := 0.0
if idx := strings.Index(lower, "bit"); idx > 0 {
j := idx
for j > 0 && lower[j-1] >= '0' && lower[j-1] <= '9' {
j--
}
if n, err := strconv.Atoi(lower[j:idx]); err == nil {
bitDepth = n
}
}
if idx := strings.Index(lower, "khz"); idx > 0 {
j := idx
for j > 0 && ((lower[j-1] >= '0' && lower[j-1] <= '9') || lower[j-1] == '.') {
j--
}
if f, err := strconv.ParseFloat(lower[j:idx], 64); err == nil {
sampleRate = f
}
}
return bitDepth, sampleRate
}
func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) {
@@ -891,6 +1071,7 @@ func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMet
Name: gojaObjectString(obj, "name"),
ImageURL: gojaObjectString(obj, "image_url", "imageUrl"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
Listeners: gojaObjectInt(obj, "listeners"),
Albums: albums,
Releases: releases,
@@ -942,35 +1123,36 @@ func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *
func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult {
obj := value.ToObject(vm)
return ExtDownloadResult{
Success: gojaObjectBool(obj, "success"),
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
Title: gojaObjectString(obj, "title"),
Artist: gojaObjectString(obj, "artist"),
Album: gojaObjectString(obj, "album"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
ISRC: gojaObjectString(obj, "isrc"),
Genre: gojaObjectString(obj, "genre"),
Label: gojaObjectString(obj, "label"),
Copyright: gojaObjectString(obj, "copyright"),
Composer: gojaObjectString(obj, "composer"),
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
Success: gojaObjectBool(obj, "success"),
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"),
Title: gojaObjectString(obj, "title"),
Artist: gojaObjectString(obj, "artist"),
Album: gojaObjectString(obj, "album"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
ISRC: gojaObjectString(obj, "isrc"),
Genre: gojaObjectString(obj, "genre"),
Label: gojaObjectString(obj, "label"),
Copyright: gojaObjectString(obj, "copyright"),
Composer: gojaObjectString(obj, "composer"),
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
RequiresContainerConversion: gojaObjectBool(
obj,
"requires_container_conversion",
@@ -982,9 +1164,11 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) {
obj := value.ToObject(vm)
handleResult := ExtURLHandleResult{
Type: gojaObjectString(obj, "type"),
Name: gojaObjectString(obj, "name"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
Type: gojaObjectString(obj, "type"),
Name: gojaObjectString(obj, "name"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
}
if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) {
@@ -2135,6 +2319,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
var lastErr error
var lastErrType string
var lastRetryAfterSeconds int
var stopProviderFallback bool
var sourceExtensionLocked bool
var sourceExtensionAvailability *ExtAvailabilityResult
@@ -2416,15 +2602,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
resp.Composer = req.Composer
}
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
} else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath)
}
embedExtensionDownloadMetadata(resp, req, alreadyExists)
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
indexISRC := strings.TrimSpace(resp.ISRC)
@@ -2449,11 +2627,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}, nil
}
lastErr = err
lastErrType = ""
} else if result.ErrorMessage != "" {
lastErr = fmt.Errorf("%s", result.ErrorMessage)
lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage)
lastRetryAfterSeconds = result.RetryAfterSeconds
}
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
if strings.EqualFold(lastErrType, "verification_required") {
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
return &DownloadResponse{
Success: false,
Error: "Download failed: " + lastErr.Error(),
ErrorType: "verification_required",
Service: req.Source,
}, nil
}
if stopProviderFallback || sourceExtensionLocked {
if sourceExtensionLocked {
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source)
@@ -2461,10 +2652,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n")
return &DownloadResponse{
Success: false,
Error: "Download failed: " + lastErr.Error(),
ErrorType: "extension_error",
Service: req.Source,
Success: false,
Error: "Download failed: " + lastErr.Error(),
ErrorType: firstNonEmptyString(lastErrType, "extension_error"),
RetryAfterSeconds: lastRetryAfterSeconds,
Service: req.Source,
}, nil
}
} else {
@@ -2483,11 +2675,13 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if providerID == "" {
continue
}
if providerID == req.Source {
// Skip the origin extension only when it differs from the explicitly
// selected provider; otherwise it must still be attempted here.
if providerID == req.Source && req.Source != selectedProvider {
continue
}
if !isExtensionFallbackAllowed(providerID) {
if providerID != selectedProvider && !isExtensionFallbackAllowed(providerID) {
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
continue
}
@@ -2516,6 +2710,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
if err != nil {
lastErr = err
if strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") {
GoLog("[DownloadWithExtensionFallback] %s requires verification (availability); pausing fallback to open the challenge\n", providerID)
return &DownloadResponse{
Success: false,
Error: "Download failed: " + err.Error(),
ErrorType: "verification_required",
Service: providerID,
}, nil
}
}
if terminalAvailability {
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID)
@@ -2530,7 +2733,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
StartItemProgress(req.ItemID)
}
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
// Honor the requested quality when this provider recognizes it
// (e.g. an explicit user selection). Only when the token is not
// one of this provider's own options do we fall back to its
// highest quality, since a source provider's token may not map.
fallbackQuality := req.Quality
if len(ext.Manifest.QualityOptions) > 0 {
requested := strings.TrimSpace(req.Quality)
recognized := false
if requested != "" {
for _, opt := range ext.Manifest.QualityOptions {
if strings.EqualFold(strings.TrimSpace(opt.ID), requested) {
recognized = true
break
}
}
}
if !recognized {
if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" {
fallbackQuality = best
}
}
}
result, err := provider.Download(availability.TrackID, fallbackQuality, outputPath, req.ItemID, func(percent int) {
if req.ItemID != "" {
normalized := float64(percent) / 100.0
if normalized < 0 {
@@ -2574,15 +2800,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
applyExtensionRequestFallbacks(&resp, req)
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
} else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath)
}
embedExtensionDownloadMetadata(resp, req, alreadyExists)
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
indexISRC := strings.TrimSpace(resp.ISRC)
@@ -2607,10 +2825,31 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}, nil
}
lastErr = err
lastErrType = ""
} else if result.ErrorMessage != "" {
lastErr = fmt.Errorf("%s", result.ErrorMessage)
lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage)
lastRetryAfterSeconds = result.RetryAfterSeconds
}
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr)
if lastErr != nil {
effType := lastErrType
if effType == "" {
effType = classifyDownloadErrorType(lastErr.Error())
}
if strings.EqualFold(effType, "verification_required") {
GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID)
return &DownloadResponse{
Success: false,
Error: "Download failed: " + lastErr.Error(),
ErrorType: "verification_required",
RetryAfterSeconds: lastRetryAfterSeconds,
Service: providerID,
}, nil
}
}
if terminalAvailability {
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID)
return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil
@@ -2619,14 +2858,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
if lastErr != nil {
errorType := classifyDownloadErrorType(lastErr.Error())
errorType := firstNonEmptyString(lastErrType, classifyDownloadErrorType(lastErr.Error()))
if errorType == "unknown" {
errorType = "not_found"
}
return &DownloadResponse{
Success: false,
Error: "All providers failed. Last error: " + lastErr.Error(),
ErrorType: errorType,
Success: false,
Error: "All providers failed. Last error: " + lastErr.Error(),
ErrorType: errorType,
RetryAfterSeconds: lastRetryAfterSeconds,
}, nil
}
@@ -2643,21 +2883,22 @@ func buildOutputPath(req DownloadRequest) string {
}
metadata := map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
"composer": req.Composer,
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"playlist_position": req.PlaylistPosition,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
"composer": req.Composer,
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
@@ -2702,21 +2943,22 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
AddAllowedDownloadDir(tempDir)
metadata := map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
"composer": req.Composer,
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"playlist_position": req.PlaylistPosition,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
"composer": req.Composer,
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
@@ -2750,6 +2992,78 @@ func canEmbedGenreLabel(filePath string) bool {
return err == nil && !info.IsDir() && info.Size() > 0
}
func embedExtensionDownloadMetadata(resp DownloadResponse, req DownloadRequest, alreadyExists bool) {
if alreadyExists || !req.EmbedMetadata {
return
}
filePath := strings.TrimSpace(resp.FilePath)
if !canEmbedGenreLabel(filePath) {
if req.Genre != "" || req.Label != "" || resp.CoverURL != "" || req.CoverURL != "" {
GoLog("[DownloadWithExtensionFallback] Skipping metadata/cover embed for non-local FLAC output path: %q\n", filePath)
}
return
}
coverURL := firstNonEmptyTrimmed(resp.CoverURL, req.CoverURL)
var coverData []byte
if coverURL != "" {
data, err := downloadCoverToMemory(coverURL, req.EmbedMaxQualityCover)
if err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to download cover for metadata embed: %v\n", err)
} else if len(data) > 0 {
coverData = data
}
}
metadata := Metadata{
Title: firstNonEmptyTrimmed(resp.Title, req.TrackName),
Artist: firstNonEmptyTrimmed(resp.Artist, req.ArtistName),
Album: firstNonEmptyTrimmed(resp.Album, req.AlbumName),
AlbumArtist: firstNonEmptyTrimmed(resp.AlbumArtist, req.AlbumArtist),
ArtistTagMode: req.ArtistTagMode,
Date: firstNonEmptyTrimmed(resp.ReleaseDate, req.ReleaseDate),
TrackNumber: firstPositiveInt(resp.TrackNumber, req.TrackNumber),
TotalTracks: firstPositiveInt(resp.TotalTracks, req.TotalTracks),
DiscNumber: firstPositiveInt(resp.DiscNumber, req.DiscNumber),
TotalDiscs: firstPositiveInt(resp.TotalDiscs, req.TotalDiscs),
ISRC: firstNonEmptyTrimmed(resp.ISRC, req.ISRC),
Genre: firstNonEmptyTrimmed(resp.Genre, req.Genre),
Label: firstNonEmptyTrimmed(resp.Label, req.Label),
Copyright: firstNonEmptyTrimmed(resp.Copyright, req.Copyright),
Composer: firstNonEmptyTrimmed(resp.Composer, req.Composer),
}
if req.EmbedLyrics {
metadata.Lyrics = resp.LyricsLRC
}
var err error
if len(coverData) > 0 {
err = EmbedMetadataWithCoverData(filePath, metadata, coverData)
} else {
err = EmbedMetadata(filePath, metadata, "")
}
if err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed metadata/cover: %v\n", err)
return
}
if len(coverData) > 0 {
GoLog("[DownloadWithExtensionFallback] Embedded metadata and cover from %q\n", coverURL)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded metadata without cover\n")
}
}
func firstPositiveInt(values ...int) int {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
return p.customSearch(query, options, "", "")
}
@@ -2873,13 +3187,15 @@ func (p *extensionProviderWrapper) customSearch(query string, options map[string
}
type ExtURLHandleResult struct {
Type string `json:"type"`
Track *ExtTrackMetadata `json:"track,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
Album *ExtAlbumMetadata `json:"album,omitempty"`
Artist *ExtArtistMetadata `json:"artist,omitempty"`
Name string `json:"name,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Type string `json:"type"`
Track *ExtTrackMetadata `json:"track,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
Album *ExtAlbumMetadata `json:"album,omitempty"`
Artist *ExtArtistMetadata `json:"artist,omitempty"`
Name string `json:"name,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"`
HeaderVideo string `json:"header_video,omitempty"`
}
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
+40
View File
@@ -8,11 +8,35 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/dop251/goja"
)
// allowPrivateNetworkAccess, when enabled, disables the SSRF guard that blocks
// requests resolving to private/local/loopback addresses. This is opt-in and
// intended for users who route the app's traffic through a local proxy or
// custom DNS (e.g. a local mirror of api.zarz.moe). Disabled by default.
var allowPrivateNetworkAccess atomic.Bool
// SetAllowPrivateNetwork toggles whether extensions and built-in network code
// are permitted to reach private/local network targets. Exposed to the Flutter
// layer via the platform bridge.
func SetAllowPrivateNetwork(allowed bool) {
allowPrivateNetworkAccess.Store(allowed)
if allowed {
GoLog("[HTTP] Private/local network access ENABLED (SSRF guard relaxed)\n")
} else {
GoLog("[HTTP] Private/local network access disabled (default)\n")
}
}
// IsPrivateNetworkAllowed reports the current state of the private-network guard.
func IsPrivateNetworkAllowed() bool {
return allowPrivateNetworkAccess.Load()
}
const DefaultJSTimeout = 30 * time.Second
var (
@@ -303,6 +327,12 @@ func (e *RedirectBlockedError) Error() string {
}
func isPrivateIP(host string) bool {
// Opt-in escape hatch: when the user has enabled private/local network
// access, treat every host as public so local proxies / custom DNS work.
if allowPrivateNetworkAccess.Load() {
return false
}
hostLower := strings.ToLower(strings.TrimSpace(host))
if hostLower == "" {
return false
@@ -465,6 +495,15 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
vm.Set("auth", authObj)
if r.manifest != nil && r.manifest.SignedSession != nil {
sessionObj := vm.NewObject()
sessionObj.Set("signedFetch", r.signedSessionFetch)
sessionObj.Set("completeGrant", r.signedSessionCompleteGrant)
sessionObj.Set("status", r.signedSessionStatus)
sessionObj.Set("clear", r.signedSessionClear)
vm.Set("session", sessionObj)
}
fileObj := vm.NewObject()
fileObj.Set("download", r.fileDownload)
fileObj.Set("exists", r.fileExists)
@@ -504,6 +543,7 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
utilsObj.Set("decryptCTRSegments", r.decryptCTRSegments)
utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
utilsObj.Set("appVersion", r.appVersion)
+204 -30
View File
@@ -158,6 +158,11 @@ func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
cloned := make([]byte, len(value))
copy(cloned, value)
return cloned, nil
case goja.ArrayBuffer:
src := value.Bytes()
cloned := make([]byte, len(src))
copy(cloned, src)
return cloned, nil
case []interface{}:
decoded := make([]byte, len(value))
for i, item := range value {
@@ -279,7 +284,9 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
"error": err.Error(),
})
}
if parsedOptions.Mode != "cbc" {
switch parsedOptions.Mode {
case "cbc", "ctr":
default:
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
@@ -303,37 +310,49 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
}
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(),
})
}
ivLabel := "iv"
if parsedOptions.Mode == "ctr" {
ivLabel = "iv (counter)"
}
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm),
})
}
var output []byte
if parsedOptions.Mode == "ctr" {
// CTR is a stream mode: encryption and decryption are identical,
// require no padding, and accept arbitrary input lengths.
output = make([]byte, len(inputData))
cipher.NewCTR(block, parsedOptions.IV).XORKeyStream(output, inputData)
} else {
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
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)
@@ -358,3 +377,158 @@ func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
return r.transformBlockCipher(call, true)
}
// decryptCTRSegments decrypts many independently-IV'd AES-CTR segments inside a
// single buffer in one host call. This exists to avoid thousands of JS->Go
// bridge crossings when an extension decrypts per-sample CENC media (each
// sample has its own IV/counter and cannot be merged into one stream).
//
// It is a generic primitive: any extension can use it for "one buffer, many
// CTR segments" workloads, not just Apple CENC.
//
// For best performance, pass the buffer as an ArrayBuffer/Uint8Array and set
// outputEncoding:"bytes" to get an ArrayBuffer back. This avoids base64
// encode/decode of the (potentially multi-MB) payload entirely, which is the
// dominant cost under the goja interpreter.
//
// JS signature:
// utils.decryptCTRSegments(data, {
// algorithm: "aes", // optional, default "aes"
// key: "<hex>", keyEncoding: "hex",
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
// ivEncoding: "base64", // encoding of each segment.iv, default base64
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
// })
// Returns { success, data, segments_processed } or { success:false, error }.
func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value {
fail := func(msg string) goja.Value {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": msg,
})
}
if len(call.Arguments) < 2 {
return fail("data and options are required")
}
options := parseRuntimeOptionsArgument(call, 1)
if options == nil {
return fail("options object is required")
}
algorithm := strings.ToLower(runtimeOptionString(options, "algorithm", "aes"))
inputEncoding := strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64"))
outputEncoding := strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64"))
ivEncoding := strings.ToLower(runtimeOptionString(options, "ivEncoding", "base64"))
key, err := decodeRuntimeBytesString(
runtimeOptionString(options, "key", ""),
runtimeOptionString(options, "keyEncoding", "hex"),
)
if err != nil {
return fail(fmt.Sprintf("invalid key: %v", err))
}
if len(key) == 0 {
return fail("key is required")
}
var block cipher.Block
switch algorithm {
case "aes":
block, err = aes.NewCipher(key)
case "blowfish":
block, err = blowfish.NewCipher(key)
default:
return fail("unsupported algorithm: " + algorithm)
}
if err != nil {
return fail(err.Error())
}
blockSize := block.BlockSize()
// Decode the payload. For "bytes" input we operate on the raw []byte
// (ArrayBuffer/Uint8Array) without any base64 round-trip.
var data []byte
if inputEncoding == "bytes" || inputEncoding == "raw" {
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), "")
if err != nil {
return fail("invalid byte payload: " + err.Error())
}
} else {
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), inputEncoding)
if err != nil {
return fail(err.Error())
}
}
rawSegments, ok := options["segments"]
if !ok || rawSegments == nil {
return fail("segments array is required")
}
segments, ok := rawSegments.([]interface{})
if !ok {
return fail("segments must be an array")
}
processed := 0
for i, rawSeg := range segments {
seg, ok := rawSeg.(map[string]interface{})
if !ok {
return fail(fmt.Sprintf("segment %d is not an object", i))
}
offset := int(runtimeOptionInt64(seg, "offset", -1))
size := int(runtimeOptionInt64(seg, "size", -1))
if offset < 0 || size < 0 {
return fail(fmt.Sprintf("segment %d has invalid offset/size", i))
}
if size == 0 {
continue
}
if offset+size > len(data) {
return fail(fmt.Sprintf("segment %d out of bounds (offset=%d size=%d len=%d)", i, offset, size, len(data)))
}
iv, err := decodeRuntimeBytesString(runtimeOptionString(seg, "iv", ""), ivEncoding)
if err != nil {
return fail(fmt.Sprintf("segment %d has invalid iv: %v", i, err))
}
if len(iv) != blockSize {
// Accept short IVs by left-aligning into a block-sized counter
// (CENC commonly uses 8-byte IVs for a 16-byte AES counter).
if len(iv) > blockSize {
return fail(fmt.Sprintf("segment %d iv longer than block size (%d > %d)", i, len(iv), blockSize))
}
padded := make([]byte, blockSize)
copy(padded, iv)
iv = padded
}
segData := data[offset : offset+size]
cipher.NewCTR(block, iv).XORKeyStream(segData, segData)
processed++
}
// Return raw bytes as an ArrayBuffer when requested (zero-copy-ish, no
// base64). Otherwise fall back to an encoded string.
if outputEncoding == "bytes" || outputEncoding == "raw" {
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": r.vm.NewArrayBuffer(data),
"segments_processed": processed,
})
}
encoded, err := encodeRuntimeBytes(data, outputEncoding)
if err != nil {
return fail(err.Error())
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": encoded,
"segments_processed": processed,
})
}
+300
View File
@@ -183,3 +183,303 @@ func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
t.Fatalf("unexpected decrypted value: %q", result.String())
}
}
func TestExtensionRuntime_BlockCipherCTRSupportsAES(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// NIST SP 800-38A, F.5.1 CTR-AES128.Encrypt test vector.
// Key: 2b7e151628aed2a6abf7158809cf4f3c
// Counter: f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff
// Plaintext: 6bc1bee22e409f96e93d7e117393172a (block 1)
// Ciphertext: 874d6191b620e3261bef6864990db6ce (block 1)
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "aes",
mode: "ctr",
key: "2b7e151628aed2a6abf7158809cf4f3c",
keyEncoding: "hex",
iv: "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
ivEncoding: "hex",
inputEncoding: "hex",
outputEncoding: "hex"
};
var enc = utils.encryptBlockCipher("6bc1bee22e409f96e93d7e117393172a", options);
if (!enc.success) throw new Error(enc.error);
// CTR is symmetric: decrypt is the same transform as encrypt.
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("aes ctr block cipher failed: %v", err)
}
decoded := decodeJSONResult[struct {
Enc string `json:"enc"`
Dec string `json:"dec"`
}](t, result)
if decoded.Enc != "874d6191b620e3261bef6864990db6ce" {
t.Fatalf("ctr ciphertext = %q, want NIST vector 874d6191b620e3261bef6864990db6ce", decoded.Enc)
}
if decoded.Dec != "6bc1bee22e409f96e93d7e117393172a" {
t.Fatalf("ctr round-trip dec = %q", decoded.Dec)
}
}
func TestExtensionRuntime_BlockCipherCTRHandlesNonBlockLength(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// CTR is a stream mode, so arbitrary (non-16-byte-aligned) input lengths
// must round-trip without any padding.
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "aes",
mode: "ctr",
key: "000102030405060708090a0b0c0d0e0f",
keyEncoding: "hex",
iv: "0f0e0d0c0b0a09080706050403020100",
ivEncoding: "hex",
inputEncoding: "utf8",
outputEncoding: "base64"
};
var enc = utils.encryptBlockCipher("stream ctr of odd length", options);
if (!enc.success) throw new Error(enc.error);
var dec = utils.decryptBlockCipher(enc.data, {
algorithm: "aes",
mode: "ctr",
key: options.key,
keyEncoding: options.keyEncoding,
iv: options.iv,
ivEncoding: options.ivEncoding,
inputEncoding: "base64",
outputEncoding: "utf8"
});
if (!dec.success) throw new Error(dec.error);
return dec.data;
})()
`)
if err != nil {
t.Fatalf("aes ctr stream length failed: %v", err)
}
if result.String() != "stream ctr of odd length" {
t.Fatalf("unexpected ctr decrypted value: %q", result.String())
}
}
func TestExtensionRuntime_BlockCipherCTRRejectsBadIV(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var res = utils.encryptBlockCipher("00112233", {
algorithm: "aes",
mode: "ctr",
key: "000102030405060708090a0b0c0d0e0f",
keyEncoding: "hex",
iv: "0001",
ivEncoding: "hex",
inputEncoding: "hex",
outputEncoding: "hex"
});
return JSON.stringify({success: res.success, error: res.error || ""});
})()
`)
if err != nil {
t.Fatalf("aes ctr bad iv eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Success bool `json:"success"`
Error string `json:"error"`
}](t, result)
if decoded.Success {
t.Fatal("expected failure for undersized CTR iv")
}
if decoded.Error == "" {
t.Fatal("expected error message for undersized CTR iv")
}
}
func TestExtensionRuntime_DecryptCTRSegmentsMatchesPerSegment(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// Build a buffer of 3 segments encrypted with distinct 8-byte IVs (CENC
// style), then verify the batch primitive decrypts all of them in one call,
// matching what per-segment decryptBlockCipher would produce.
result, err := vm.RunString(`
(function() {
var keyHex = "000102030405060708090a0b0c0d0e0f";
function b64(bytes){return utils.base64Encode(utils.toHex ? bytes : bytes);}
// segment plaintexts (hex) and 8-byte IVs (hex)
var segs = [
{ pt: "11111111111111111111", iv: "0000000000000001" },
{ pt: "2222222222", iv: "0000000000000002" },
{ pt: "333333333333333333333333", iv: "00000000000000ff" }
];
// Encrypt each segment individually using single-shot CTR with a
// 16-byte counter (8-byte iv left-aligned), producing ciphertext hex.
function ivToB64(ivHex){
// pad 8-byte hex iv to 16 bytes then base64
var full = ivHex + "00000000000000000000000000000000".slice(ivHex.length);
return utils.base64Encode(utils.hexToBytes ? utils.hexToBytes(full) : full);
}
var cipherHex = "";
var offsets = [];
var off = 0;
var ivB64s = [];
for (var i=0;i<segs.length;i++){
var ivFullHex = (segs[i].iv + "00000000000000000000000000000000").slice(0,32);
var enc = utils.encryptBlockCipher(segs[i].pt, {
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
iv: ivFullHex, ivEncoding:"hex",
inputEncoding:"hex", outputEncoding:"hex"
});
if(!enc.success) throw new Error("enc seg "+i+": "+enc.error);
cipherHex += enc.data;
var sz = segs[i].pt.length/2;
offsets.push({offset: off, size: sz, ivHex: ivFullHex});
off += sz;
}
// Now decrypt the whole concatenated buffer in ONE batch call.
var segments = offsets.map(function(o){
return { offset:o.offset, size:o.size, iv:o.ivHex };
});
var batch = utils.decryptCTRSegments(cipherHex, {
algorithm:"aes", key:keyHex, keyEncoding:"hex",
segments: segments, ivEncoding:"hex",
inputEncoding:"hex", outputEncoding:"hex"
});
if(!batch.success) throw new Error("batch: "+batch.error);
var expected = "";
for (var j=0;j<segs.length;j++) expected += segs[j].pt;
return JSON.stringify({
out: batch.data,
expected: expected,
processed: batch.segments_processed
});
})()
`)
if err != nil {
t.Fatalf("batch CTR eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Out string `json:"out"`
Expected string `json:"expected"`
Processed int `json:"processed"`
}](t, result)
if decoded.Out != decoded.Expected {
t.Fatalf("batch decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
}
if decoded.Processed != 3 {
t.Fatalf("segments_processed = %d, want 3", decoded.Processed)
}
}
func TestExtensionRuntime_DecryptCTRSegmentsRejectsOutOfBounds(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var res = utils.decryptCTRSegments("00112233", {
algorithm:"aes", key:"000102030405060708090a0b0c0d0e0f", keyEncoding:"hex",
inputEncoding:"hex", outputEncoding:"hex",
ivEncoding:"hex",
segments: [ { offset: 0, size: 99, iv: "00000000000000000000000000000000" } ]
});
return JSON.stringify({ success: res.success, error: res.error || "" });
})()
`)
if err != nil {
t.Fatalf("oob eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Success bool `json:"success"`
Error string `json:"error"`
}](t, result)
if decoded.Success {
t.Fatal("expected out-of-bounds segment to fail")
}
if decoded.Error == "" {
t.Fatal("expected error message for out-of-bounds segment")
}
}
func TestExtensionRuntime_DecryptCTRSegmentsRawBytes(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// Verify the zero-base64 path: pass an ArrayBuffer in, request bytes out,
// and confirm round-trip correctness against single-shot CTR.
result, err := vm.RunString(`
(function() {
var keyHex = "000102030405060708090a0b0c0d0e0f";
var ivFullHex = "0000000000000001" + "00000000000000000000000000000000".slice(16);
// Plaintext as a Uint8Array of 20 bytes.
var pt = new Uint8Array(20);
for (var i = 0; i < pt.length; i++) pt[i] = (i * 7 + 3) & 0xff;
// Encrypt single-shot to get ciphertext (hex output for clarity).
var ptHex = "";
for (var j = 0; j < pt.length; j++) { var h = pt[j].toString(16); ptHex += (h.length === 1 ? "0" : "") + h; }
var enc = utils.encryptBlockCipher(ptHex, {
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
iv: ivFullHex, ivEncoding:"hex", inputEncoding:"hex", outputEncoding:"base64"
});
if (!enc.success) throw new Error("enc: " + enc.error);
// Decode ciphertext base64 into a Uint8Array to feed the raw path.
var cipherBytes = utils.base64Decode ? null : null;
// Build ArrayBuffer from base64 via Uint8Array manually:
var b64 = enc.data;
var bin = (typeof atob === "function") ? null : null;
// Simpler: ask the host to give us bytes by decrypting nothing is hard,
// so just pass the base64 ciphertext through decryptCTRSegments using
// base64 input but bytes output, then re-run with bytes input.
var step1 = utils.decryptCTRSegments(b64, {
algorithm:"aes", key:keyHex, keyEncoding:"hex",
segments: [ { offset:0, size:20, iv: ivFullHex } ],
ivEncoding:"hex", inputEncoding:"base64", outputEncoding:"bytes"
});
if (!step1.success) throw new Error("step1: " + step1.error);
if (typeof step1.data === "string") throw new Error("expected ArrayBuffer output, got string");
var outArr = new Uint8Array(step1.data);
var outHex = "";
for (var k = 0; k < outArr.length; k++) { var hh = outArr[k].toString(16); outHex += (hh.length === 1 ? "0" : "") + hh; }
return JSON.stringify({ out: outHex, expected: ptHex, len: outArr.length });
})()
`)
if err != nil {
t.Fatalf("raw-bytes eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Out string `json:"out"`
Expected string `json:"expected"`
Len int `json:"len"`
}](t, result)
if decoded.Out != decoded.Expected {
t.Fatalf("raw-bytes decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
}
if decoded.Len != 20 {
t.Fatalf("output length = %d, want 20", decoded.Len)
}
}
+14 -5
View File
@@ -370,7 +370,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
var totalSize int64
contentRange := probeResp.Header.Get("Content-Range")
if contentRange != "" {
// Format: "bytes 0-1/12345"
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
sizeStr := contentRange[idx+1:]
if sizeStr != "*" {
@@ -457,7 +456,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
break // Success
}
// Non-success status
io.Copy(io.Discard, chunkResp.Body)
chunkResp.Body.Close()
@@ -474,7 +472,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
})
}
// Read chunk body and write to file
chunkWritten := int64(0)
for {
nr, er := chunkResp.Body.Read(buf)
@@ -663,7 +660,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
"error": "offset must be >= 0",
})
}
file, err := os.Open(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -716,6 +712,20 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
}
}
if strings.EqualFold(strings.TrimSpace(encoding), "bytes") ||
strings.EqualFold(strings.TrimSpace(encoding), "raw") {
// Return raw bytes as an ArrayBuffer to avoid base64 encode/decode of
// large payloads under the goja interpreter.
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": r.vm.NewArrayBuffer(data),
"bytes_read": len(data),
"offset": offset,
"size": size,
"eof": offset+int64(len(data)) >= size,
})
}
encoded, err := encodeRuntimeBytes(data, encoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -733,7 +743,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
"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{}{
+664
View File
@@ -0,0 +1,664 @@
package gobackend
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
const signedSessionRefreshSkew = time.Hour
var (
pendingSignedSessionGrants = make(map[string]string)
pendingSignedSessionGrantsMu sync.Mutex
)
type signedSessionRecord struct {
InstallID string `json:"install_id"`
SessionID string `json:"session_id,omitempty"`
SessionSecret string `json:"session_secret,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Namespace string `json:"namespace,omitempty"`
BaseURL string `json:"base_url,omitempty"`
AppVersion string `json:"app_version,omitempty"`
Platform string `json:"platform,omitempty"`
}
type signedSessionExchangeResponse struct {
SessionID string `json:"session_id,omitempty"`
SessionSecret string `json:"session_secret,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
ChallengeID string `json:"challenge_id,omitempty"`
ChallengeURL string `json:"challenge_url,omitempty"`
AuthURL string `json:"auth_url,omitempty"`
}
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
if config == nil {
return SignedSessionConfig{}
}
resolved := *config
if resolved.AppVersion == "" {
resolved.AppVersion = "ext-1.0"
}
if resolved.Platform == "" {
resolved.Platform = "extension"
}
if resolved.CallbackURL == "" {
resolved.CallbackURL = "spotiflac://session-grant"
}
if resolved.SchemeLabel == "" {
resolved.SchemeLabel = "SPOTIFLAC-HMAC-V1"
}
if resolved.HeaderPrefix == "" {
resolved.HeaderPrefix = "X-Sig-"
}
if resolved.TimeWindowSeconds <= 0 {
resolved.TimeWindowSeconds = 300
}
if resolved.Endpoints.Bootstrap == "" {
resolved.Endpoints.Bootstrap = "/bootstrap"
}
if resolved.Endpoints.Challenge == "" {
resolved.Endpoints.Challenge = "/challenge"
}
if resolved.Endpoints.Exchange == "" {
resolved.Endpoints.Exchange = "/session/exchange"
}
return resolved
}
func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (string, error) {
namespace := sanitizeSignedSessionNamespace(config.Namespace)
if namespace == "" {
return "", fmt.Errorf("signed session namespace is empty")
}
baseDir := filepath.Dir(r.dataDir)
if baseDir == "." || baseDir == "" {
baseDir = r.dataDir
}
dir := filepath.Join(baseDir, "signed_sessions")
if err := os.MkdirAll(dir, 0700); err != nil {
return "", err
}
scope := strings.Join([]string{
namespace,
strings.TrimSpace(strings.ToLower(config.BaseURL)),
strings.TrimSpace(strings.ToLower(config.AppVersion)),
strings.TrimSpace(strings.ToLower(config.Platform)),
}, "\n")
sum := sha256.Sum256([]byte(scope))
return filepath.Join(dir, namespace+"-"+hex.EncodeToString(sum[:])[:16]+".json"), nil
}
func sanitizeSignedSessionNamespace(namespace string) string {
namespace = strings.TrimSpace(strings.ToLower(namespace))
var b strings.Builder
for _, ch := range namespace {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' {
b.WriteRune(ch)
}
}
return strings.Trim(b.String(), ".-_")
}
func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
path, err := r.signedSessionFilePath(config)
if err != nil {
return nil, err
}
record := &signedSessionRecord{}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, record)
}
if strings.TrimSpace(record.InstallID) == "" {
record.InstallID = randomHex(16)
}
normalizeSignedSessionRecordScope(config, record)
if err := r.saveSignedSession(config, record); err != nil {
return nil, err
}
return record, nil
}
func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) {
namespace := sanitizeSignedSessionNamespace(config.Namespace)
baseURL := strings.TrimSpace(config.BaseURL)
appVersion := strings.TrimSpace(config.AppVersion)
platform := strings.TrimSpace(config.Platform)
if record.Namespace == "" && record.BaseURL == "" && record.AppVersion == "" && record.Platform == "" {
record.Namespace = namespace
record.BaseURL = baseURL
record.AppVersion = appVersion
record.Platform = platform
return
}
if record.Namespace != namespace ||
record.BaseURL != baseURL ||
record.AppVersion != appVersion ||
record.Platform != platform {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
}
record.Namespace = namespace
record.BaseURL = baseURL
record.AppVersion = appVersion
record.Platform = platform
}
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
path, err := r.signedSessionFilePath(config)
if err != nil {
return err
}
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func randomHex(bytesLen int) string {
buf := make([]byte, bytesLen)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
func parseSignedSessionTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.000Z",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": "signedSession is not configured"})
}
record, err := r.loadSignedSession(config)
if err != nil {
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": err.Error()})
}
authenticated := record.SessionID != "" && record.SessionSecret != ""
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok && time.Now().After(expiresAt) {
authenticated = false
}
return r.vm.ToValue(map[string]interface{}{
"authenticated": authenticated,
"expires_at": record.ExpiresAt,
"install_id": record.InstallID,
"session_id": record.SessionID,
"app_version": config.AppVersion,
"platform": config.Platform,
})
}
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
record, err := r.loadSignedSession(config)
if err != nil {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
}
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
if err := r.saveSignedSession(config, record); err != nil {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
}
ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]interface{}{"success": true})
}
func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) goja.Value {
grant := ""
if len(call.Arguments) > 0 {
grant = strings.TrimSpace(call.Arguments[0].String())
}
if grant == "" {
pendingSignedSessionGrantsMu.Lock()
grant = pendingSignedSessionGrants[r.extensionID]
delete(pendingSignedSessionGrants, r.extensionID)
pendingSignedSessionGrantsMu.Unlock()
}
if grant == "" {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": "no pending grant"})
}
if err := r.exchangeSignedSessionGrant(grant); err != nil {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
}
ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]interface{}{"success": true})
}
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
record, err := r.loadSignedSession(config)
if err != nil {
return err
}
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
if err != nil {
return err
}
payload := map[string]interface{}{
"grant": grant,
"install_id": record.InstallID,
"app_version": config.AppVersion,
"platform": config.Platform,
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
resp, err := r.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode)
}
var exchanged signedSessionExchangeResponse
if err := json.Unmarshal(respBody, &exchanged); err != nil {
return fmt.Errorf("invalid session exchange response: %w", err)
}
if exchanged.SessionID == "" || exchanged.SessionSecret == "" || exchanged.ExpiresAt == "" {
return fmt.Errorf("session exchange response missing session fields")
}
record.SessionID = exchanged.SessionID
record.SessionSecret = exchanged.SessionSecret
record.ExpiresAt = exchanged.ExpiresAt
return r.saveSignedSession(config, record)
}
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "method and path are required"})
}
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "signedSession is not configured"})
}
method := strings.ToUpper(strings.TrimSpace(call.Arguments[0].String()))
requestPath := call.Arguments[1].String()
body := []byte{}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
switch v := call.Arguments[2].Export().(type) {
case string:
body = []byte(v)
case map[string]interface{}, []interface{}:
encoded, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
}
body = encoded
default:
body = []byte(call.Arguments[2].String())
}
}
extraHeaders := map[string]string{}
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
if h, ok := call.Arguments[3].Export().(map[string]interface{}); ok {
for k, v := range h {
extraHeaders[k] = fmt.Sprintf("%v", v)
}
}
}
record, err := r.ensureSignedSession(config)
if err != nil {
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
return r.signedSessionVerificationRequiredValue(authURL)
}
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
}
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders)
if err != nil {
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
_ = r.saveSignedSession(config, record)
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
return r.signedSessionVerificationRequiredValue(authURL)
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(respBody),
"headers": respHeaders,
"retryAfterSeconds": signedSessionRetryAfterSeconds(resp),
})
}
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
return r.vm.ToValue(map[string]interface{}{
"ok": false,
"needsVerification": true,
"error": "VERIFY_REQUIRED",
"open_auth_url": authURL,
"auth_url": authURL,
})
}
func (r *extensionRuntime) ensureSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
record, err := r.loadSignedSession(config)
if err != nil {
return nil, err
}
if record.SessionID == "" || record.SessionSecret == "" {
return nil, fmt.Errorf("signed session is not authenticated")
}
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
if time.Now().After(expiresAt) {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
_ = r.saveSignedSession(config, record)
return nil, fmt.Errorf("signed session expired")
}
if config.Endpoints.Refresh != "" && time.Until(expiresAt) <= signedSessionRefreshSkew {
_ = r.refreshSignedSession(config, record)
}
}
return record, nil
}
func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
body, _ := json.Marshal(map[string]string{"install_id": record.InstallID})
resp, respBody, _, err := r.doSignedSessionRequest(config, record, http.MethodPost, config.Endpoints.Refresh, body, nil)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("session refresh failed: HTTP %d", resp.StatusCode)
}
var refreshed signedSessionExchangeResponse
if err := json.Unmarshal(respBody, &refreshed); err != nil {
return err
}
changed := false
if refreshed.SessionID != "" {
record.SessionID = refreshed.SessionID
changed = true
}
if refreshed.SessionSecret != "" {
record.SessionSecret = refreshed.SessionSecret
changed = true
}
if refreshed.ExpiresAt != "" && refreshed.ExpiresAt != record.ExpiresAt {
record.ExpiresAt = refreshed.ExpiresAt
changed = true
}
if changed {
return r.saveSignedSession(config, record)
}
return nil
}
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) string {
record, err := r.loadSignedSession(config)
if err != nil {
return ""
}
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
if err != nil {
return ""
}
parsed, _ := url.Parse(bootstrapURL)
query := parsed.Query()
query.Set("app_version", config.AppVersion)
query.Set("install_id", record.InstallID)
parsed.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
if err != nil {
return ""
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
resp, err := r.httpClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ""
}
var boot signedSessionExchangeResponse
if err := json.Unmarshal(body, &boot); err != nil {
return ""
}
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
record.SessionID = boot.SessionID
record.SessionSecret = boot.SessionSecret
record.ExpiresAt = boot.ExpiresAt
_ = r.saveSignedSession(config, record)
return ""
}
authURL := boot.AuthURL
if authURL == "" && boot.ChallengeURL != "" {
authURL = boot.ChallengeURL
}
if authURL == "" && boot.ChallengeID != "" {
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
}
if authURL != "" {
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: authURL,
CallbackURL: config.CallbackURL,
}
pendingAuthRequestsMu.Unlock()
}
return authURL
}
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
challengeURL, err := signedSessionURL(config, config.Endpoints.Challenge)
if err != nil {
return ""
}
parsed, err := url.Parse(challengeURL)
if err != nil {
return ""
}
callback, err := url.Parse(config.CallbackURL)
if err != nil {
return ""
}
q := callback.Query()
q.Set("cb_version", "v2grant")
q.Set("state", r.extensionID)
callback.RawQuery = q.Encode()
query := parsed.Query()
query.Set("id", challengeID)
query.Set("cb", callback.String())
parsed.RawQuery = query.Encode()
return parsed.String()
}
func signedSessionURL(config SignedSessionConfig, endpoint string) (string, error) {
base, err := url.Parse(strings.TrimRight(config.BaseURL, "/") + "/")
if err != nil || base.Scheme != "https" || base.Host == "" {
return "", fmt.Errorf("invalid signed session baseUrl")
}
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return "", fmt.Errorf("signed session endpoint is empty")
}
if strings.HasPrefix(endpoint, "https://") {
return endpoint, nil
}
endpoint = strings.TrimLeft(endpoint, "/")
ref, _ := url.Parse(endpoint)
return base.ResolveReference(ref).String(), nil
}
func (r *extensionRuntime) doSignedSessionRequest(
config SignedSessionConfig,
record *signedSessionRecord,
method string,
requestPath string,
body []byte,
extraHeaders map[string]string,
) (*http.Response, []byte, map[string]interface{}, error) {
fullURL, err := signedSessionURL(config, requestPath)
if err != nil {
return nil, nil, nil, err
}
parsed, err := url.Parse(fullURL)
if err != nil {
return nil, nil, nil, err
}
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
nonce := randomHex(12)
bodyHashBytes := sha256.Sum256(body)
bodyHash := hex.EncodeToString(bodyHashBytes[:])
parsedTs, _ := time.Parse("2006-01-02T15:04:05.000Z", ts)
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
rollingInput := fmt.Sprintf("%d:%s", window, record.SessionID)
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(record.SessionSecret), []byte(rollingInput)))
signingInput := strings.Join([]string{
config.SchemeLabel,
method,
parsed.EscapedPath(),
"",
bodyHash,
ts,
nonce,
record.SessionID,
config.AppVersion,
config.Platform,
}, "\n")
sig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
req, err := http.NewRequest(method, fullURL, bytes.NewReader(body))
if err != nil {
return nil, nil, nil, err
}
req = r.bindDownloadCancelContext(req)
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
prefix := config.HeaderPrefix
req.Header.Set(prefix+"Session", record.SessionID)
req.Header.Set(prefix+"Timestamp", ts)
req.Header.Set(prefix+"Nonce", nonce)
req.Header.Set(prefix+"Body-SHA256", bodyHash)
req.Header.Set(prefix+"Signature", sig)
req.Header.Set(prefix+"App-Version", config.AppVersion)
req.Header.Set(prefix+"Platform", config.Platform)
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, nil, nil, err
}
defer resp.Body.Close()
respBody, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return nil, nil, nil, err
}
headers := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
headers[k] = v[0]
} else {
headers[k] = v
}
}
return resp, respBody, headers, nil
}
func signedSessionRetryAfterSeconds(resp *http.Response) int {
if resp == nil {
return 0
}
value := strings.TrimSpace(resp.Header.Get("Retry-After"))
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil {
if seconds < 0 {
return 0
}
return seconds
}
if retryAt, err := http.ParseTime(value); err == nil {
seconds := int(time.Until(retryAt).Seconds())
if seconds < 0 {
return 0
}
return seconds
}
return 0
}
func hmacSHA256Bytes(key, message []byte) []byte {
mac := hmac.New(sha256.New, key)
mac.Write(message)
return mac.Sum(nil)
}
func setPendingSignedSessionGrant(extensionID, grant string) {
extensionID = strings.TrimSpace(extensionID)
grant = strings.TrimSpace(grant)
if extensionID == "" || grant == "" {
return
}
pendingSignedSessionGrantsMu.Lock()
pendingSignedSessionGrants[extensionID] = grant
pendingSignedSessionGrantsMu.Unlock()
}
+11 -7
View File
@@ -330,22 +330,26 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
return result, nil
}
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
func (s *extensionStore) findExtension(extensionID string) (*storeExtension, error) {
registry, err := s.fetchRegistry(false)
if err != nil {
return err
return nil, err
}
var ext *storeExtension
for _, e := range registry.Extensions {
if e.ID == extensionID {
ext = &e
break
ext := e
return &ext, nil
}
}
if ext == nil {
return fmt.Errorf("extension %s not found in store", extensionID)
return nil, fmt.Errorf("extension %s not found in store", extensionID)
}
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
ext, err := s.findExtension(extensionID)
if err != nil {
return err
}
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
+15 -1
View File
@@ -13,7 +13,7 @@ import (
var (
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
multiUnderscore = regexp.MustCompile(`_+`)
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc|playlist_position|playlistPosition|position):([0-9]+)\}`)
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
yearPattern = regexp.MustCompile(`\d{4}`)
)
@@ -99,6 +99,11 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
"{album}": getString(metadata, "album"),
"{track}": formatTrackNumber(getInt(metadata, "track")),
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
"{playlist_position}": formatTrackNumber(getPlaylistPosition(metadata)),
"{playlist position}": formatTrackNumber(getPlaylistPosition(metadata)),
"{playlistPosition}": formatTrackNumber(getPlaylistPosition(metadata)),
"{position}": formatTrackNumber(getPlaylistPosition(metadata)),
"{playlist_position_raw}": formatRawNumber(getPlaylistPosition(metadata)),
"{year}": yearValue,
"{date}": dateValue,
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
@@ -120,6 +125,9 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]int
}
number := getInt(metadata, parts[1])
if parts[1] == "playlist_position" || parts[1] == "playlistPosition" || parts[1] == "position" {
number = getPlaylistPosition(metadata)
}
width, err := strconv.Atoi(parts[2])
if err != nil {
return ""
@@ -177,6 +185,8 @@ func getInt(m map[string]interface{}, key string) int {
candidateKeys = append(candidateKeys, "track_number")
case "disc":
candidateKeys = append(candidateKeys, "disc_number")
case "playlist_position", "playlistPosition", "playlist position", "position":
candidateKeys = append(candidateKeys, "playlistPosition", "playlist position", "position")
}
for _, candidate := range candidateKeys {
@@ -200,6 +210,10 @@ func getInt(m map[string]interface{}, key string) int {
return 0
}
func getPlaylistPosition(metadata map[string]interface{}) int {
return getInt(metadata, "playlist_position")
}
func formatTrackNumber(n int) string {
if n <= 0 {
return ""
+17
View File
@@ -55,6 +55,23 @@ func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
}
}
func TestBuildFilenameFromTemplate_PlaylistPositionFormatting(t *testing.T) {
metadata := map[string]interface{}{
"playlist_position": 4,
"artist": "Artist Name",
"title": "Song Name",
}
formatted := buildFilenameFromTemplate(
"{playlist_position:02} - {artist} - {title}",
metadata,
)
expected := "04 - Artist Name - Song Name"
if formatted != expected {
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
metadata := map[string]interface{}{
"artist": "Artist Name",
+11 -11
View File
@@ -5,25 +5,25 @@ go 1.25.0
toolchain go1.25.9
require (
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59
github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2
golang.org/x/crypto v0.52.0
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a
golang.org/x/net v0.55.0
golang.org/x/text v0.37.0
golang.org/x/crypto v0.53.0
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2
golang.org/x/net v0.56.0
golang.org/x/text v0.38.0
)
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/tools v0.47.0 // indirect
)
+26 -26
View File
@@ -1,13 +1,13 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 h1:DjKLmvKK9u15djHZ88N8M0DhgnHVgJJ8bnEe0h7Lga8=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
@@ -16,10 +16,12 @@ github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sY
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -30,23 +32,21 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a h1:sEcsLeiCTTaHGWn+v81+PLAOzzOA9wmzNRqr1WfCmVY=
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 h1:zoM1gIKhVkcQNm43kad8OHLgPNoJ12xIqmxHtKr8Mug=
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2/go.mod h1:QGMqsqLn6orFQ/ksqYMf+Fa33Soa1vPoHEd0Pj7N+lQ=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+117 -73
View File
@@ -1,7 +1,9 @@
package gobackend
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
@@ -437,101 +439,143 @@ func (e *ISPBlockingError) Error() string {
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
}
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
// isTransientNetworkError reports retryable transport failures such as
// timeouts and temporary DNS errors. Permanent DNS misses are excluded.
func isTransientNetworkError(err error) bool {
if err == nil {
return nil
return false
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
var netErr net.Error
return errors.As(err, &netErr) && (netErr.Timeout() || netErr.Temporary())
}
// isConnectivityFailure reports DNS, dial, timeout, TLS, or truncated transport
// errors. Application-level API messages are excluded.
func isConnectivityFailure(err error) bool {
return connectivityFailureReason(err) != ""
}
func connectivityFailureReason(err error) string {
if err == nil {
return ""
}
if errors.Is(err, context.DeadlineExceeded) {
return "Request timed out - ISP may be throttling"
}
if errors.Is(err, io.ErrUnexpectedEOF) {
return "Connection closed unexpectedly - ISP may be blocking"
}
domain := extractDomain(requestURL)
errStr := strings.ToLower(err.Error())
var urlErr *url.Error
if errors.As(err, &urlErr) {
if urlErr.Timeout() {
return "Connection timed out - ISP may be blocking access"
}
if urlErr.Err != nil {
if reason := connectivityFailureReason(urlErr.Err); reason != "" {
return reason
}
}
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound || dnsErr.IsTemporary {
return &ISPBlockingError{
Domain: domain,
Reason: "DNS resolution failed - domain may be blocked by ISP",
OriginalErr: err,
}
if dnsErr.IsNotFound || dnsErr.IsTimeout || dnsErr.IsTemporary {
return "DNS resolution failed - domain may be blocked by ISP"
}
}
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Op == "dial" {
var syscallErr syscall.Errno
if errors.As(opErr.Err, &syscallErr) {
switch syscallErr {
case syscall.ECONNREFUSED:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection refused - port may be blocked by ISP/firewall",
OriginalErr: err,
}
case syscall.ECONNRESET:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection reset - ISP may be intercepting traffic",
OriginalErr: err,
}
case syscall.ETIMEDOUT:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection timed out - ISP may be blocking access",
OriginalErr: err,
}
case syscall.ENETUNREACH:
return &ISPBlockingError{
Domain: domain,
Reason: "Network unreachable - ISP may be blocking route",
OriginalErr: err,
}
case syscall.EHOSTUNREACH:
return &ISPBlockingError{
Domain: domain,
Reason: "Host unreachable - ISP may be blocking destination",
OriginalErr: err,
}
}
if opErr.Timeout() {
return "Connection timed out - ISP may be blocking access"
}
var errno syscall.Errno
if errors.As(opErr.Err, &errno) {
switch errno {
case syscall.ECONNREFUSED:
return "Connection refused - port may be blocked by ISP/firewall"
case syscall.ECONNRESET:
return "Connection reset - ISP may be intercepting traffic"
case syscall.ETIMEDOUT:
return "Connection timed out - ISP may be blocking access"
case syscall.ENETUNREACH:
return "Network unreachable - ISP may be blocking route"
case syscall.EHOSTUNREACH:
return "Host unreachable - ISP may be blocking destination"
}
}
}
var tlsErr *tls.RecordHeaderError
if errors.As(err, &tlsErr) {
return &ISPBlockingError{
Domain: domain,
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
OriginalErr: err,
return "TLS handshake failed - ISP may be intercepting HTTPS traffic"
}
var certErr x509.CertificateInvalidError
if errors.As(err, &certErr) {
return "Certificate error - ISP may be using MITM proxy"
}
var hostnameErr x509.HostnameError
if errors.As(err, &hostnameErr) {
return "Certificate error - ISP may be using MITM proxy"
}
var unknownAuth x509.UnknownAuthorityError
if errors.As(err, &unknownAuth) {
return "Certificate error - ISP may be using MITM proxy"
}
return ""
}
// isTLSHandshakeOrResetError reports TLS handshake/cert failures and TCP resets
// that should trigger a Chrome fingerprint retry.
func isTLSHandshakeOrResetError(err error) bool {
if err == nil {
return false
}
var recordErr *tls.RecordHeaderError
if errors.As(err, &recordErr) {
return true
}
var certErr x509.CertificateInvalidError
if errors.As(err, &certErr) {
return true
}
var hostnameErr x509.HostnameError
if errors.As(err, &hostnameErr) {
return true
}
var unknownAuth x509.UnknownAuthorityError
if errors.As(err, &unknownAuth) {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) {
var errno syscall.Errno
if errors.As(opErr.Err, &errno) && errno == syscall.ECONNRESET {
return true
}
}
return false
}
blockingPatterns := []struct {
pattern string
reason string
}{
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
{"connection refused", "Connection refused - port may be blocked"},
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
{"certificate", "Certificate error - ISP may be using MITM proxy"},
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil {
return nil
}
for _, bp := range blockingPatterns {
if strings.Contains(errStr, bp.pattern) {
return &ISPBlockingError{
Domain: domain,
Reason: bp.reason,
OriginalErr: err,
}
}
reason := connectivityFailureReason(err)
if reason == "" {
return nil
}
return &ISPBlockingError{
Domain: extractDomain(requestURL),
Reason: reason,
OriginalErr: err,
}
return nil
}
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
+15 -4
View File
@@ -1,13 +1,15 @@
package gobackend
import (
"context"
"crypto/x509"
"encoding/pem"
"errors"
"io"
"net"
"net/http"
"net/url"
"strings"
"syscall"
"testing"
"time"
)
@@ -131,15 +133,24 @@ func TestHTTPUtilityHelpers(t *testing.T) {
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
t.Fatal("invalid retry-after should be zero")
}
if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
resetErr := &net.OpError{Op: "read", Err: syscall.ECONNRESET}
if isp := IsISPBlocking(resetErr, "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
t.Fatalf("IsISPBlocking = %#v", isp)
}
if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") {
timeoutErr := &net.OpError{Op: "dial", Err: syscall.ETIMEDOUT}
if !CheckAndLogISPBlocking(timeoutErr, "https://timeout.example/x", "test") {
t.Fatal("expected logged ISP blocking")
}
if wrapped := WrapErrorWithISPCheck(errors.New("connection refused"), "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
refusedErr := &net.OpError{Op: "dial", Err: syscall.ECONNREFUSED}
if wrapped := WrapErrorWithISPCheck(refusedErr, "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
t.Fatalf("WrapErrorWithISPCheck = %v", wrapped)
}
if !isTransientNetworkError(context.DeadlineExceeded) || isTransientNetworkError(&net.DNSError{IsNotFound: true}) {
t.Fatal("isTransientNetworkError mismatch")
}
if !isConnectivityFailure(&net.DNSError{IsNotFound: true}) || !isConnectivityFailure(context.DeadlineExceeded) {
t.Fatal("isConnectivityFailure mismatch")
}
if WrapErrorWithISPCheck(nil, "", "test") != nil {
t.Fatal("nil wrap should stay nil")
}
+1 -7
View File
@@ -144,13 +144,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
return resp, nil
}
errStr := strings.ToLower(err.Error())
tlsRelated := strings.Contains(errStr, "tls") ||
strings.Contains(errStr, "handshake") ||
strings.Contains(errStr, "certificate") ||
strings.Contains(errStr, "connection reset")
if tlsRelated {
if isTLSHandshakeOrResetError(err) {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
reqCopy := req.Clone(req.Context())
+204 -29
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
@@ -76,6 +77,9 @@ var supportedAudioFormats = map[string]bool{
".ape": true,
".wv": true,
".mpc": true,
".wav": true,
".aiff": true,
".aif": true,
".cue": true,
}
@@ -89,6 +93,18 @@ type scannedCueFileInfo struct {
audioPath string
}
type libraryScanTask struct {
index int
info libraryAudioFileInfo
}
type libraryScanTaskResult struct {
index int
path string
results []LibraryScanResult
err error
}
func isLibraryStagingFile(path string) bool {
name := strings.ToLower(filepath.Base(path))
if strings.HasSuffix(name, ".partial") {
@@ -147,6 +163,129 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
return files, nil
}
func libraryScanWorkerCount(taskCount int) int {
if taskCount < 16 {
return 1
}
workers := runtime.NumCPU()
if workers > 4 {
workers = 4
}
if workers < 2 {
workers = 2
}
if workers > taskCount {
workers = taskCount
}
return workers
}
func updateLibraryScanProgress(scannedFiles, totalFiles int, currentPath string) {
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = scannedFiles
libraryScanProgress.CurrentFile = filepath.Base(currentPath)
if totalFiles > 0 {
libraryScanProgress.ProgressPct = float64(scannedFiles) / float64(totalFiles) * 100
}
libraryScanProgressMu.Unlock()
}
func scanLibraryAudioTasksParallel(tasks []libraryScanTask, scanTime string, cancelCh <-chan struct{}, totalFiles int, completed *int) (map[int][]LibraryScanResult, int, error) {
resultsByIndex := make(map[int][]LibraryScanResult, len(tasks))
if len(tasks) == 0 {
return resultsByIndex, 0, nil
}
workers := libraryScanWorkerCount(len(tasks))
if workers <= 1 {
errorCount := 0
for _, task := range tasks {
select {
case <-cancelCh:
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
default:
}
result, err := scanAudioFileWithKnownModTime(task.info.path, scanTime, task.info.modTime)
*completed++
updateLibraryScanProgress(*completed, totalFiles, task.info.path)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", task.info.path, err)
continue
}
resultsByIndex[task.index] = []LibraryScanResult{*result}
}
return resultsByIndex, errorCount, nil
}
taskCh := make(chan libraryScanTask)
resultCh := make(chan libraryScanTaskResult, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range taskCh {
select {
case <-cancelCh:
return
default:
}
result, err := scanAudioFileWithKnownModTime(task.info.path, scanTime, task.info.modTime)
taskResult := libraryScanTaskResult{
index: task.index,
path: task.info.path,
err: err,
}
if err == nil && result != nil {
taskResult.results = []LibraryScanResult{*result}
}
select {
case <-cancelCh:
return
case resultCh <- taskResult:
}
}
}()
}
go func() {
defer close(taskCh)
for _, task := range tasks {
select {
case <-cancelCh:
return
case taskCh <- task:
}
}
}()
go func() {
wg.Wait()
close(resultCh)
}()
errorCount := 0
for taskResult := range resultCh {
*completed++
updateLibraryScanProgress(*completed, totalFiles, taskResult.path)
if taskResult.err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", taskResult.path, taskResult.err)
continue
}
resultsByIndex[taskResult.index] = taskResult.results
}
select {
case <-cancelCh:
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
default:
}
return resultsByIndex, errorCount, nil
}
func SetLibraryCoverCacheDir(cacheDir string) {
libraryCoverCacheMu.Lock()
libraryCoverCacheDir = cacheDir
@@ -222,6 +361,10 @@ func ScanLibraryFolder(folderPath string) (string, error) {
}
}
resultsByIndex := make(map[int][]LibraryScanResult, totalFiles)
audioTasks := make([]libraryScanTask, 0, totalFiles)
completedFiles := 0
for i, fileInfo := range audioFileInfos {
filePath := fileInfo.path
select {
@@ -230,12 +373,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = i + 1
libraryScanProgress.CurrentFile = filepath.Base(filePath)
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
ext := strings.ToLower(filepath.Ext(filePath))
if ext == ".cue" {
@@ -257,26 +394,44 @@ func ScanLibraryFolder(folderPath string) (string, error) {
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
continue
}
results = append(results, cueResults...)
resultsByIndex[i] = cueResults
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
continue
}
if cueReferencedAudioFiles[filePath] {
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue
}
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
continue
}
audioTasks = append(audioTasks, libraryScanTask{index: i, info: fileInfo})
}
results = append(results, *result)
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
audioTasks,
scanTime,
cancelCh,
totalFiles,
&completedFiles,
)
if err != nil {
return "[]", err
}
errorCount += audioErrors
for index, scanResults := range audioResults {
resultsByIndex[index] = scanResults
}
for i := range audioFileInfos {
results = append(results, resultsByIndex[i]...)
}
libraryScanProgressMu.Lock()
@@ -340,6 +495,10 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
return scanOggFile(filePath, result, displayNameHint)
case ".ape", ".wv", ".mpc":
return scanAPEFile(filePath, result, displayNameHint)
case ".wav":
return scanWAVFile(filePath, result, displayNameHint)
case ".aiff", ".aif", ".aifc":
return scanAIFFFile(filePath, result, displayNameHint)
default:
return scanFromFilename(filePath, displayNameHint, result)
}
@@ -479,7 +638,7 @@ func libraryFormatForM4ACodec(codec string) string {
func isLosslessLibraryFormat(format string) bool {
switch strings.ToLower(strings.TrimSpace(format)) {
case "flac", "alac":
case "flac", "alac", "wav", "aiff", "aif", "aifc":
return true
default:
return false
@@ -867,6 +1026,10 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
}
}
resultsByIndex := make(map[int][]LibraryScanResult, len(filesToScan))
audioTasks := make([]libraryScanTask, 0, len(filesToScan))
completedFiles := skippedCount
for i, f := range filesToScan {
select {
case <-cancelCh:
@@ -874,12 +1037,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = skippedCount + i + 1
libraryScanProgress.CurrentFile = filepath.Base(f.path)
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
ext := strings.ToLower(filepath.Ext(f.path))
if ext == ".cue" {
@@ -901,24 +1058,42 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
results = append(results, cueResults...)
resultsByIndex[i] = cueResults
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
if cueReferencedAudioFilesInc[f.path] {
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
continue
}
audioTasks = append(audioTasks, libraryScanTask{index: i, info: f})
}
results = append(results, *result)
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
audioTasks,
scanTime,
cancelCh,
totalFiles,
&completedFiles,
)
if err != nil {
return "{}", err
}
errorCount += audioErrors
for index, scanResults := range audioResults {
resultsByIndex[index] = scanResults
}
for i := range filesToScan {
results = append(results, resultsByIndex[i]...)
}
libraryScanProgressMu.Lock()
+555 -119
View File
@@ -20,6 +20,12 @@ const (
durationToleranceSec = 10.0
)
const (
lyricsProviderUnavailableCooldown = 10 * time.Minute
lyricsProviderParallelism = 3
lyricsProviderPriorityGrace = 5000 * time.Millisecond
)
const (
LyricsProviderLRCLIB = "lrclib"
LyricsProviderNetease = "netease"
@@ -31,6 +37,7 @@ const (
LyricsProviderYouTube = "youtube"
LyricsProviderKugou = "kugou"
LyricsProviderGenius = "genius"
LyricsProviderLyricsPlus = "lyricsplus"
)
var DefaultLyricsProviders = []string{
@@ -45,6 +52,33 @@ var (
appVersion string
)
type lyricsProviderHealthEntry struct {
unavailableUntil time.Time
reason string
}
type lyricsProviderSearchRequest struct {
spotifyID string
trackName string
artistName string
primaryArtist string
simplifiedTrack string
durationSec float64
fetchOptions LyricsFetchOptions
}
type lyricsProviderSearchResult struct {
index int
providerName string
lyrics *LyricsResponse
err error
}
var (
lyricsProviderHealthMu sync.RWMutex
lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry)
)
func SetAppVersion(version string) {
normalized := strings.TrimSpace(version)
@@ -98,6 +132,7 @@ func SetLyricsProviderOrder(providers []string) {
if len(providers) == 0 {
lyricsProviders = nil
clearLyricsProviderHealth()
return
}
@@ -112,6 +147,7 @@ func SetLyricsProviderOrder(providers []string) {
LyricsProviderYouTube: true,
LyricsProviderKugou: true,
LyricsProviderGenius: true,
LyricsProviderLyricsPlus: true,
}
var valid []string
@@ -123,9 +159,131 @@ func SetLyricsProviderOrder(providers []string) {
}
lyricsProviders = valid
clearLyricsProviderHealth()
GoLog("[Lyrics] Provider order set to: %v\n", valid)
}
func clearLyricsProviderHealth() {
lyricsProviderHealthMu.Lock()
defer lyricsProviderHealthMu.Unlock()
lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry)
}
func lyricsProviderHealthKey(providerName string) string {
return strings.ToLower(strings.TrimSpace(providerName))
}
func shouldSkipLyricsProvider(providerName string) (bool, time.Duration, string) {
key := lyricsProviderHealthKey(providerName)
if key == "" {
return false, 0, ""
}
now := time.Now()
lyricsProviderHealthMu.RLock()
entry, ok := lyricsProviderHealth[key]
lyricsProviderHealthMu.RUnlock()
if !ok {
return false, 0, ""
}
if !now.Before(entry.unavailableUntil) {
lyricsProviderHealthMu.Lock()
if current, exists := lyricsProviderHealth[key]; exists && !now.Before(current.unavailableUntil) {
delete(lyricsProviderHealth, key)
}
lyricsProviderHealthMu.Unlock()
return false, 0, ""
}
return true, time.Until(entry.unavailableUntil), entry.reason
}
func markLyricsProviderAvailable(providerName string) {
key := lyricsProviderHealthKey(providerName)
if key == "" {
return
}
lyricsProviderHealthMu.Lock()
delete(lyricsProviderHealth, key)
lyricsProviderHealthMu.Unlock()
}
func markLyricsProviderUnavailable(providerName string, err error) {
if err == nil || !isLyricsProviderUnavailableError(err) {
return
}
key := lyricsProviderHealthKey(providerName)
if key == "" {
return
}
reason := strings.TrimSpace(err.Error())
if len(reason) > 160 {
reason = reason[:160]
}
unavailableUntil := time.Now().Add(lyricsProviderUnavailableCooldown)
lyricsProviderHealthMu.Lock()
lyricsProviderHealth[key] = lyricsProviderHealthEntry{
unavailableUntil: unavailableUntil,
reason: reason,
}
lyricsProviderHealthMu.Unlock()
GoLog("[Lyrics] Provider %s marked unavailable for %s: %s\n", providerName, lyricsProviderUnavailableCooldown, reason)
}
var lyricsNotFoundSignals = []string{
"lyrics not found",
"no lyrics found",
"no songs found",
"not found on",
"empty track",
"empty search query",
"needs a deezer id",
}
// Provider/API-level failures that should temporarily disable a lyrics source.
// Transport failures are handled by isConnectivityFailure via typed errors.
var lyricsServiceUnavailableSignals = []string{
"fetch failed",
"missing required parameters",
"request failed",
"request unsuccessful",
"search failed",
"search unavailable",
"rate limit",
"too many requests",
"operation too frequent",
"操作频繁",
"proxy returned http 429",
"proxy returned http 5",
"unexpected status code: 429",
"unexpected status code: 5",
"unexpected response code",
"returned http 429",
"returned http 5",
}
func isLyricsProviderUnavailableError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
for _, signal := range lyricsNotFoundSignals {
if strings.Contains(msg, signal) {
return false
}
}
if isConnectivityFailure(err) {
return true
}
for _, signal := range lyricsServiceUnavailableSignals {
if strings.Contains(msg, signal) {
return true
}
}
return false
}
func GetLyricsProviderOrder() []string {
lyricsProvidersMu.RLock()
defer lyricsProvidersMu.RUnlock()
@@ -151,6 +309,7 @@ func GetAvailableLyricsProviders() []map[string]interface{} {
{"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"},
{"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"},
{"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"},
{"id": LyricsProviderLyricsPlus, "name": "LyricsPlus", "has_proxy_dependency": true, "description": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ)"},
}
}
@@ -471,15 +630,22 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
if len(extensionProviders) > 0 {
for _, provider := range extensionProviders {
providerName := "extension:" + provider.extension.ID
if skip, remaining, reason := shouldSkipLyricsProvider(providerName); skip {
GoLog("[Lyrics] Skipping unavailable extension lyrics provider %s for %s: %s\n", provider.extension.ID, remaining.Round(time.Second), reason)
continue
}
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
if err == nil && isValidResult(lyrics) {
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
markLyricsProviderAvailable(providerName)
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
if err != nil {
GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err)
markLyricsProviderUnavailable(providerName, err)
}
}
}
@@ -493,144 +659,338 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
providerOrder := GetLyricsProviderOrder()
simplifiedTrack := simplifyTrackName(trackName)
request := lyricsProviderSearchRequest{
spotifyID: spotifyID,
trackName: trackName,
artistName: artistName,
primaryArtist: primaryArtist,
simplifiedTrack: simplifiedTrack,
durationSec: durationSec,
fetchOptions: fetchOptions,
}
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
for _, providerName := range providerOrder {
GoLog("[Lyrics] Trying provider: %s\n", providerName)
lyrics, err := fetchBuiltInLyricsProviders(providerOrder, request, c.fetchBuiltInLyricsProvider)
if err == nil && isValidResult(lyrics) {
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
var lyrics *LyricsResponse
var err error
return nil, fmt.Errorf("lyrics not found from any source")
}
switch providerName {
case LyricsProviderLRCLIB:
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
func fetchBuiltInLyricsProviders(
providerOrder []string,
request lyricsProviderSearchRequest,
fetchProvider func(string, lyricsProviderSearchRequest) (*LyricsResponse, error, bool),
) (*LyricsResponse, error) {
type providerCandidate struct {
index int
name string
}
case LyricsProviderNetease:
neteaseClient := NewNeteaseClient()
lyrics, err = neteaseClient.FetchLyrics(
trackName,
primaryArtist,
durationSec,
fetchOptions.IncludeTranslationNetease,
fetchOptions.IncludeRomanizationNetease,
)
if err != nil && primaryArtist != artistName {
lyrics, err = neteaseClient.FetchLyrics(
trackName,
artistName,
durationSec,
fetchOptions.IncludeTranslationNetease,
fetchOptions.IncludeRomanizationNetease,
)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = neteaseClient.FetchLyrics(
simplifiedTrack,
primaryArtist,
durationSec,
fetchOptions.IncludeTranslationNetease,
fetchOptions.IncludeRomanizationNetease,
)
}
candidates := make([]providerCandidate, 0, len(providerOrder))
results := make(chan lyricsProviderSearchResult, len(providerOrder))
sem := make(chan struct{}, lyricsProviderParallelism)
var wg sync.WaitGroup
case LyricsProviderMusixmatch:
musixmatchClient := NewMusixmatchClient()
lyrics, err = musixmatchClient.FetchLyrics(
trackName,
primaryArtist,
durationSec,
fetchOptions.MusixmatchLanguage,
)
if err != nil && primaryArtist != artistName {
lyrics, err = musixmatchClient.FetchLyrics(
trackName,
artistName,
durationSec,
fetchOptions.MusixmatchLanguage,
)
}
for index, providerName := range providerOrder {
if skip, remaining, reason := shouldSkipLyricsProvider(providerName); skip {
GoLog("[Lyrics] Skipping unavailable provider %s for %s: %s\n", providerName, remaining.Round(time.Second), reason)
continue
}
case LyricsProviderAppleMusic:
appleClient := NewAppleMusicClient()
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
if err != nil && primaryArtist != artistName {
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
}
case LyricsProviderQQMusic:
qqClient := NewQQMusicClient()
lyrics, err = qqClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
if err != nil && primaryArtist != artistName {
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
}
case LyricsProviderSpotify:
spotifyClient := NewSpotifyLyricsClient()
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = spotifyClient.FetchLyrics("", simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderDeezer:
deezerClient := NewDeezerLyricsClient()
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
case LyricsProviderYouTube:
youtubeClient := NewYouTubeLyricsClient()
lyrics, err = youtubeClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = youtubeClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = youtubeClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderKugou:
kugouClient := NewKugouLyricsClient()
lyrics, err = kugouClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = kugouClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = kugouClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderGenius:
geniusClient := NewGeniusLyricsClient()
lyrics, err = geniusClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = geniusClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
default:
knownProvider := isKnownBuiltInLyricsProvider(providerName)
if !knownProvider {
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
continue
}
if err == nil && isValidResult(lyrics) {
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
candidate := providerCandidate{index: index, name: providerName}
candidates = append(candidates, candidate)
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
GoLog("[Lyrics] Trying provider: %s\n", candidate.name)
lyrics, err, ok := fetchProvider(candidate.name, request)
if !ok {
results <- lyricsProviderSearchResult{index: candidate.index, providerName: candidate.name, err: fmt.Errorf("unknown provider")}
return
}
if err == nil && lyricsHasUsableText(lyrics) {
GoLog("[Lyrics] Got lyrics from: %s\n", candidate.name)
markLyricsProviderAvailable(candidate.name)
} else if err != nil {
GoLog("[Lyrics] Provider %s failed: %v\n", candidate.name, err)
markLyricsProviderUnavailable(candidate.name, err)
}
results <- lyricsProviderSearchResult{index: candidate.index, providerName: candidate.name, lyrics: lyrics, err: err}
}()
}
if len(candidates) == 0 {
return nil, fmt.Errorf("lyrics not found from any source")
}
go func() {
wg.Wait()
close(results)
}()
completed := make(map[int]bool, len(candidates))
var best *lyricsProviderSearchResult
var lastErr error
var graceTimer *time.Timer
var grace <-chan time.Time
stopGrace := func() {
if graceTimer != nil {
if !graceTimer.Stop() {
select {
case <-graceTimer.C:
default:
}
}
graceTimer = nil
grace = nil
}
}
defer stopGrace()
hasPendingEarlier := func(index int) bool {
for _, candidate := range candidates {
if candidate.index >= index {
return false
}
if !completed[candidate.index] {
return true
}
}
return false
}
for remaining := len(candidates); remaining > 0; {
if best != nil && !hasPendingEarlier(best.index) {
return best.lyrics, nil
}
if best != nil && graceTimer == nil {
graceTimer = time.NewTimer(lyricsProviderPriorityGrace)
grace = graceTimer.C
}
if err != nil {
GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
select {
case result, ok := <-results:
if !ok {
remaining = 0
break
}
remaining--
completed[result.index] = true
if result.err != nil {
lastErr = result.err
}
if lyricsHasUsableText(result.lyrics) && (best == nil || result.index < best.index) {
copied := result
best = &copied
stopGrace()
}
case <-grace:
if best != nil {
GoLog("[Lyrics] Returning provider %s after %s priority grace\n", best.providerName, lyricsProviderPriorityGrace)
return best.lyrics, nil
}
}
}
if best != nil {
return best.lyrics, nil
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("lyrics not found from any source")
}
func isKnownBuiltInLyricsProvider(providerName string) bool {
switch providerName {
case LyricsProviderLRCLIB,
LyricsProviderNetease,
LyricsProviderMusixmatch,
LyricsProviderAppleMusic,
LyricsProviderQQMusic,
LyricsProviderSpotify,
LyricsProviderDeezer,
LyricsProviderYouTube,
LyricsProviderKugou,
LyricsProviderGenius,
LyricsProviderLyricsPlus:
return true
default:
return false
}
}
func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
switch providerName {
case LyricsProviderLRCLIB:
lyrics, err := c.tryLRCLIB(request.primaryArtist, request.artistName, request.trackName, request.simplifiedTrack, request.durationSec)
return lyrics, err, true
case LyricsProviderNetease:
neteaseClient := NewNeteaseClient()
lyrics, err := neteaseClient.FetchLyrics(
request.trackName,
request.primaryArtist,
request.durationSec,
request.fetchOptions.IncludeTranslationNetease,
request.fetchOptions.IncludeRomanizationNetease,
)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = neteaseClient.FetchLyrics(
request.trackName,
request.artistName,
request.durationSec,
request.fetchOptions.IncludeTranslationNetease,
request.fetchOptions.IncludeRomanizationNetease,
)
}
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
lyrics, err = neteaseClient.FetchLyrics(
request.simplifiedTrack,
request.primaryArtist,
request.durationSec,
request.fetchOptions.IncludeTranslationNetease,
request.fetchOptions.IncludeRomanizationNetease,
)
}
return lyrics, err, true
case LyricsProviderMusixmatch:
musixmatchClient := NewMusixmatchClient()
lyrics, err := musixmatchClient.FetchLyrics(
request.trackName,
request.primaryArtist,
request.durationSec,
request.fetchOptions.MusixmatchLanguage,
)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = musixmatchClient.FetchLyrics(
request.trackName,
request.artistName,
request.durationSec,
request.fetchOptions.MusixmatchLanguage,
)
}
return lyrics, err, true
case LyricsProviderAppleMusic:
appleClient := NewAppleMusicClient()
lyrics, err := appleClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec, request.fetchOptions.MultiPersonWordByWord, request.fetchOptions.AppleElrcWordSync)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = appleClient.FetchLyrics(request.trackName, request.artistName, request.durationSec, request.fetchOptions.MultiPersonWordByWord, request.fetchOptions.AppleElrcWordSync)
}
return lyrics, err, true
case LyricsProviderQQMusic:
qqClient := NewQQMusicClient()
lyrics, err := qqClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec, request.fetchOptions.MultiPersonWordByWord)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = qqClient.FetchLyrics(request.trackName, request.artistName, request.durationSec, request.fetchOptions.MultiPersonWordByWord)
}
return lyrics, err, true
case LyricsProviderSpotify:
spotifyClient := NewSpotifyLyricsClient()
lyrics, err := spotifyClient.FetchLyrics(request.spotifyID, request.trackName, request.primaryArtist, request.durationSec)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = spotifyClient.FetchLyrics(request.spotifyID, request.trackName, request.artistName, request.durationSec)
}
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
lyrics, err = spotifyClient.FetchLyrics("", request.simplifiedTrack, request.primaryArtist, request.durationSec)
}
return lyrics, err, true
case LyricsProviderDeezer:
deezerClient := NewDeezerLyricsClient()
lyrics, err := deezerClient.FetchLyrics(request.spotifyID, request.trackName, request.primaryArtist, request.durationSec)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = deezerClient.FetchLyrics(request.spotifyID, request.trackName, request.artistName, request.durationSec)
}
return lyrics, err, true
case LyricsProviderYouTube:
youtubeClient := NewYouTubeLyricsClient()
lyrics, err := youtubeClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = youtubeClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
}
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
lyrics, err = youtubeClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec)
}
return lyrics, err, true
case LyricsProviderKugou:
kugouClient := NewKugouLyricsClient()
lyrics, err := kugouClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = kugouClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
}
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
lyrics, err = kugouClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec)
}
return lyrics, err, true
case LyricsProviderGenius:
geniusClient := NewGeniusLyricsClient()
lyrics, err := geniusClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = geniusClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
}
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
lyrics, err = geniusClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec)
}
return lyrics, err, true
case LyricsProviderLyricsPlus:
lyricsPlusClient := NewLyricsPlusClient()
lyrics, err := lyricsPlusClient.FetchLyrics(
request.trackName,
request.primaryArtist,
"",
request.durationSec,
request.fetchOptions.MultiPersonWordByWord,
request.fetchOptions.AppleElrcWordSync,
)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = lyricsPlusClient.FetchLyrics(
request.trackName,
request.artistName,
"",
request.durationSec,
request.fetchOptions.MultiPersonWordByWord,
request.fetchOptions.AppleElrcWordSync,
)
}
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
lyrics, err = lyricsPlusClient.FetchLyrics(
request.simplifiedTrack,
request.primaryArtist,
"",
request.durationSec,
request.fetchOptions.MultiPersonWordByWord,
request.fetchOptions.AppleElrcWordSync,
)
}
return lyrics, err, true
default:
return nil, fmt.Errorf("unknown provider: %s", providerName), false
}
}
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
var lyrics *LyricsResponse
var err error
@@ -640,6 +1000,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
if primaryArtist != artistName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
@@ -647,6 +1010,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
}
if simplifiedTrack != trackName {
@@ -655,6 +1021,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB (simplified)"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
}
query := primaryArtist + " " + trackName
@@ -663,6 +1032,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB Search"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
if simplifiedTrack != trackName {
query = primaryArtist + " " + simplifiedTrack
@@ -671,6 +1043,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB Search (simplified)"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
}
return nil, fmt.Errorf("LRCLIB: no lyrics found")
@@ -814,6 +1189,18 @@ func detectLyricsErrorPayload(raw string) (string, bool) {
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
return "request unsuccessful", true
}
if isError, ok := payload["isError"].(bool); ok && isError && !hasLyricsKey {
return "request unsuccessful", true
}
if code, ok := payload["code"].(float64); ok && code != 0 && code != 200 && !hasLyricsKey {
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
return strings.TrimSpace(msg), true
}
if msg, ok := payload["msg"].(string); ok && strings.TrimSpace(msg) != "" {
return strings.TrimSpace(msg), true
}
return fmt.Sprintf("unexpected response code %.0f", code), true
}
return "", false
}
@@ -843,6 +1230,41 @@ func msToLRCTimestampInline(ms int64) string {
return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds)
}
// extractLyricsSourceFromLRC reads the provider recorded in the LRC [by:] tag,
// e.g. "[by:SpotiFLAC-Mobile (source: LRCLIB)]". Returns "" when absent.
const lrcSourceMarker = "(source: "
func lyricsSourceUsesPaxsenix(source string) bool {
s := strings.ToLower(strings.TrimSpace(source))
if s == "" {
return false
}
if strings.HasPrefix(s, "lrclib") ||
strings.HasPrefix(s, "extension:") ||
strings.HasPrefix(s, "heuristic") {
return false
}
return true
}
func extractLyricsSourceFromLRC(lrc string) string {
for _, line := range strings.Split(lrc, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(strings.ToLower(trimmed), "[by:") {
continue
}
idx := strings.Index(trimmed, lrcSourceMarker)
if idx < 0 {
return ""
}
rest := strings.TrimSpace(trimmed[idx+len(lrcSourceMarker):])
rest = strings.TrimSuffix(rest, "]")
rest = strings.TrimSuffix(rest, ")")
return strings.TrimSpace(rest)
}
return ""
}
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
if lyrics == nil || len(lyrics.Lines) == 0 {
return ""
@@ -852,7 +1274,21 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
source := strings.TrimSpace(lyrics.Source)
if source == "" {
source = strings.TrimSpace(lyrics.Provider)
}
credit := "SpotiFLAC-Mobile"
if lyricsSourceUsesPaxsenix(source) {
credit = "SpotiFLAC-Mobile via Paxsenix API"
}
if source == "" {
builder.WriteString(fmt.Sprintf("[by:%s]\n", credit))
} else {
builder.WriteString(
fmt.Sprintf("[by:%s %s%s)]\n", credit, lrcSourceMarker, source),
)
}
builder.WriteString("\n")
if lyrics.SyncType == "LINE_SYNCED" {
+6 -3
View File
@@ -2,6 +2,7 @@ package gobackend
import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
@@ -13,6 +14,8 @@ import (
"time"
)
var errAppleMusicUnauthorized = errors.New("apple music catalog search unauthorized")
type AppleMusicClient struct {
httpClient *http.Client
}
@@ -188,7 +191,7 @@ func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
return "", fmt.Errorf("failed to read apple music script: %w", err)
}
token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody))
token := regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`).FindString(string(jsBody))
if token == "" {
return "", fmt.Errorf("apple music token not found")
}
@@ -235,7 +238,7 @@ func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusi
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("apple music catalog search unauthorized")
return nil, errAppleMusicUnauthorized
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
@@ -281,7 +284,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
}
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
if errors.Is(err, errAppleMusicUnauthorized) {
clearAppleMusicToken()
token, tokenErr := c.getAppleMusicToken()
if tokenErr != nil {
+239
View File
@@ -0,0 +1,239 @@
package gobackend
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// LyricsPlus (KPOE) provider.
//
// LyricsPlus aggregates word-by-word ("karaoke") synced lyrics from Apple
// Music, Musixmatch, Spotify and QQ Music via a community-run backend. It
// frequently has word-level timing for tracks that other providers only offer
// line-synced or not at all.
//
// API: GET {server}/v2/lyrics/get?title=&artist=&album=&duration=&isrc=
// The response is the KPOE JSON format which we convert into the same enhanced
// LRC text the Apple/QQ providers emit, so embedding/export behaves identically.
// Public LyricsPlus / KPOE servers (mirrors). Tried in order with failover.
// Sourced from the upstream YouLy+ client server list.
var lyricsPlusServers = []string{
"https://lyricsplus.prjktla.workers.dev",
"https://lyricsplus.binimum.org",
}
type LyricsPlusClient struct {
httpClient *http.Client
}
func NewLyricsPlusClient() *LyricsPlusClient {
return &LyricsPlusClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
type lyricsPlusSyllable struct {
Text string `json:"text"`
Time float64 `json:"time"` // absolute ms
Duration float64 `json:"duration"` // ms
IsBackground bool `json:"isBackground"`
}
type lyricsPlusLine struct {
Time float64 `json:"time"` // absolute ms
Duration float64 `json:"duration"` // ms
Text string `json:"text"`
Syllabus []lyricsPlusSyllable `json:"syllabus"`
}
type lyricsPlusResponse struct {
Type string `json:"type"` // "Word" | "Line" | "Syllable" | "None"
Lyrics []lyricsPlusLine `json:"lyrics"`
}
// FetchLyrics tries each LyricsPlus server in order until one returns usable
// lyrics. multiPersonWordByWord and preserveWordTiming mirror the Apple/QQ
// options so word/background timing is only emitted when the user enabled it.
func (c *LyricsPlusClient) FetchLyrics(
trackName,
artistName,
isrc string,
durationSec float64,
multiPersonWordByWord bool,
preserveWordTiming bool,
) (*LyricsResponse, error) {
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
return nil, fmt.Errorf("lyricsplus: missing track or artist")
}
var lastErr error
for _, server := range lyricsPlusServers {
lyrics, err := c.fetchFromServer(server, trackName, artistName, isrc, durationSec, multiPersonWordByWord, preserveWordTiming)
if err == nil && lyricsHasUsableText(lyrics) {
return lyrics, nil
}
if err != nil {
lastErr = err
GoLog("[Lyrics] LyricsPlus server %s failed: %v\n", server, err)
}
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("lyricsplus: no lyrics found")
}
func (c *LyricsPlusClient) fetchFromServer(
server,
trackName,
artistName,
isrc string,
durationSec float64,
multiPersonWordByWord bool,
preserveWordTiming bool,
) (*LyricsResponse, error) {
base := strings.TrimRight(strings.TrimSpace(server), "/")
if base == "" {
return nil, fmt.Errorf("empty server")
}
params := url.Values{}
params.Set("title", trackName)
params.Set("artist", artistName)
if durationSec > 0 {
params.Set("duration", strconv.FormatFloat(durationSec, 'f', 3, 64))
}
if strings.TrimSpace(isrc) != "" {
params.Set("isrc", strings.TrimSpace(isrc))
}
fullURL := base + "/v2/lyrics/get?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
// Retry without the ISRC filter, which can be too strict.
if strings.TrimSpace(isrc) != "" {
return c.fetchFromServer(server, trackName, artistName, "", durationSec, multiPersonWordByWord, preserveWordTiming)
}
return nil, fmt.Errorf("lyrics not found")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var payload lyricsPlusResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("failed to decode lyricsplus response: %w", err)
}
if len(payload.Lyrics) == 0 {
return nil, fmt.Errorf("lyricsplus returned no lines")
}
lrcText := buildLyricsPlusLRC(&payload, multiPersonWordByWord, preserveWordTiming)
if strings.TrimSpace(lrcText) == "" {
return nil, fmt.Errorf("lyricsplus produced empty lyrics")
}
lyrics := lyricsResponseFromText(lrcText, "LyricsPlus")
return lyrics, nil
}
// buildLyricsPlusLRC converts the KPOE JSON into enhanced LRC text. When word
// timing is available and enabled, each syllable is emitted as an inline
// <mm:ss.xx> tag (matching the Apple/QQ output); otherwise a line-synced LRC
// is produced from the full line text.
func buildLyricsPlusLRC(resp *lyricsPlusResponse, multiPersonWordByWord bool, preserveWordTiming bool) string {
isWordType := strings.EqualFold(resp.Type, "Word") || strings.EqualFold(resp.Type, "Syllable")
var sb strings.Builder
first := true
for _, line := range resp.Lyrics {
lineText := line.Text
hasSyllables := len(line.Syllabus) > 0
timestamp := msToLRCTimestamp(int64(line.Time))
if isWordType && preserveWordTiming && hasSyllables {
mainSyllables := make([]lyricsPlusSyllable, 0, len(line.Syllabus))
bgSyllables := make([]lyricsPlusSyllable, 0)
for _, syl := range line.Syllabus {
if syl.IsBackground {
bgSyllables = append(bgSyllables, syl)
} else {
mainSyllables = append(mainSyllables, syl)
}
}
if len(mainSyllables) == 0 {
mainSyllables = line.Syllabus
bgSyllables = nil
}
if !first {
sb.WriteString("\n")
}
first = false
sb.WriteString(timestamp)
appendLyricsPlusSyllables(&sb, mainSyllables)
if multiPersonWordByWord && len(bgSyllables) > 0 {
sb.WriteString("\n[bg:")
appendLyricsPlusSyllables(&sb, bgSyllables)
sb.WriteString("]")
}
continue
}
// Line-synced fallback. Reconstruct text from syllables if needed.
if strings.TrimSpace(lineText) == "" && hasSyllables {
var lineBuilder strings.Builder
for _, syl := range line.Syllabus {
lineBuilder.WriteString(syl.Text)
}
lineText = lineBuilder.String()
}
lineText = strings.TrimSpace(lineText)
if lineText == "" {
continue
}
if !first {
sb.WriteString("\n")
}
first = false
sb.WriteString(timestamp)
sb.WriteString(lineText)
}
return strings.TrimSpace(sb.String())
}
// appendLyricsPlusSyllables writes each syllable as "<mm:ss.xx>text". KPOE
// already embeds spacing inside the syllable text, so no extra spaces are added.
func appendLyricsPlusSyllables(sb *strings.Builder, syllables []lyricsPlusSyllable) {
for _, syl := range syllables {
sb.WriteString("<")
sb.WriteString(msToLRCTimestampInline(int64(syl.Time)))
sb.WriteString(">")
sb.WriteString(syl.Text)
}
}
+14 -1
View File
@@ -24,7 +24,9 @@ type neteaseSearchResponse struct {
} `json:"songs"`
SongCount int `json:"songCount"`
} `json:"result"`
Code int `json:"code"`
Code int `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"`
}
type neteaseLyricsResponse struct {
@@ -87,6 +89,17 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
return 0, fmt.Errorf("failed to decode netease search: %w", err)
}
if searchResp.Code != 0 && searchResp.Code != 200 {
message := strings.TrimSpace(searchResp.Message)
if message == "" {
message = strings.TrimSpace(searchResp.Msg)
}
if message == "" {
message = "unexpected response code"
}
return 0, fmt.Errorf("netease search unavailable: code %d: %s", searchResp.Code, message)
}
if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 {
return 0, fmt.Errorf("no songs found on netease")
}
+1 -1
View File
@@ -463,7 +463,7 @@ func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSe
params := url.Values{}
params.Set("q", query)
params.Set("per_page", "10")
params.Set("per_page", "5")
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
if err != nil {
return "", fmt.Errorf("genius search failed: %w", err)
+131 -1
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"errors"
"io"
"net/http"
"path/filepath"
@@ -54,6 +55,15 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
if msg, ok := detectLyricsErrorPayload(`{"success":false,"message":"nope"}`); !ok || msg != "nope" {
t.Fatalf("error payload = %q/%v", msg, ok)
}
if msg, ok := detectLyricsErrorPayload(`{"isError":true,"error":"Missing required parameters"}`); !ok || msg != "Missing required parameters" {
t.Fatalf("isError payload = %q/%v", msg, ok)
}
if msg, ok := detectLyricsErrorPayload(`{"code":405,"message":"rate limited"}`); !ok || msg != "rate limited" {
t.Fatalf("coded error payload = %q/%v", msg, ok)
}
if !isLyricsProviderUnavailableError(errors.New("rate limit")) {
t.Fatal("expected rate-limit errors to mark provider unavailable")
}
if lrcTimestampToMs("01", "02", "345") != 62345 || msToLRCTimestamp(62340) != "[01:02.34]" {
t.Fatal("unexpected LRC timestamp conversion")
}
@@ -130,9 +140,120 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
}
}
func TestLyricsProviderHealthSkipsUnavailableProvider(t *testing.T) {
SetLyricsProviderOrder([]string{LyricsProviderLRCLIB})
defer SetLyricsProviderOrder(nil)
globalLyricsCache.ClearAll()
clearLyricsProviderHealth()
defer clearLyricsProviderHealth()
calls := 0
downClient := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 503, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`service unavailable`)), Request: req}, nil
})}}
if lyrics, err := downClient.FetchLyricsAllSources("", "Down Song", "Artist", 180); err == nil || lyrics != nil {
t.Fatalf("expected unavailable provider error, got %#v/%v", lyrics, err)
}
if calls != 1 {
t.Fatalf("expected one HTTP call before cooldown, got %d", calls)
}
if skip, _, _ := shouldSkipLyricsProvider(LyricsProviderLRCLIB); !skip {
t.Fatal("expected LRCLIB to be marked unavailable")
}
if lyrics, err := downClient.FetchLyricsAllSources("", "Another Song", "Artist", 180); err == nil || lyrics != nil {
t.Fatalf("expected skipped provider error, got %#v/%v", lyrics, err)
}
if calls != 1 {
t.Fatalf("provider was called while in cooldown, calls=%d", calls)
}
clearLyricsProviderHealth()
globalLyricsCache.ClearAll()
notFoundCalls := 0
notFoundClient := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
notFoundCalls++
switch req.URL.Path {
case "/api/get":
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
case "/api/search":
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[]`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})}}
if lyrics, err := notFoundClient.FetchLyricsAllSources("", "missing song", "Artist", 180); err == nil || lyrics != nil {
t.Fatalf("expected not found error, got %#v/%v", lyrics, err)
}
if skip, _, _ := shouldSkipLyricsProvider(LyricsProviderLRCLIB); skip {
t.Fatal("not-found result must not mark provider unavailable")
}
if lyrics, err := notFoundClient.FetchLyricsAllSources("", "missing song 2", "Artist", 180); err == nil || lyrics != nil {
t.Fatalf("expected second not found error, got %#v/%v", lyrics, err)
}
if notFoundCalls != 4 {
t.Fatalf("expected not-found provider to be retried, calls=%d", notFoundCalls)
}
}
func TestConcurrentLyricsProvidersReturnFastFallback(t *testing.T) {
clearLyricsProviderHealth()
defer clearLyricsProviderHealth()
start := time.Now()
lyrics, err := fetchBuiltInLyricsProviders(
[]string{LyricsProviderLRCLIB, LyricsProviderAppleMusic},
lyricsProviderSearchRequest{},
func(providerName string, _ lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
if providerName == LyricsProviderLRCLIB {
time.Sleep(lyricsProviderPriorityGrace + 800*time.Millisecond)
return &LyricsResponse{Provider: "LRCLIB", PlainLyrics: "slow"}, nil, true
}
return &LyricsResponse{Provider: "Apple Music", PlainLyrics: "fast"}, nil, true
},
)
if err != nil {
t.Fatalf("concurrent providers returned error: %v", err)
}
if lyrics == nil || lyrics.Provider != "Apple Music" {
t.Fatalf("expected fast fallback lyrics, got %#v", lyrics)
}
if elapsed := time.Since(start); elapsed >= lyricsProviderPriorityGrace+700*time.Millisecond {
t.Fatalf("fallback waited too long: %s", elapsed)
}
}
func TestConcurrentLyricsProvidersPreferEarlierProviderWithinGrace(t *testing.T) {
clearLyricsProviderHealth()
defer clearLyricsProviderHealth()
lyrics, err := fetchBuiltInLyricsProviders(
[]string{LyricsProviderLRCLIB, LyricsProviderAppleMusic},
lyricsProviderSearchRequest{},
func(providerName string, _ lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
if providerName == LyricsProviderLRCLIB {
time.Sleep(50 * time.Millisecond)
return &LyricsResponse{Provider: "LRCLIB", PlainLyrics: "preferred"}, nil, true
}
return &LyricsResponse{Provider: "Apple Music", PlainLyrics: "fast"}, nil, true
},
)
if err != nil {
t.Fatalf("concurrent providers returned error: %v", err)
}
if lyrics == nil || lyrics.Provider != "LRCLIB" {
t.Fatalf("expected preferred provider lyrics, got %#v", lyrics)
}
}
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
clearAppleMusicToken()
defer clearAppleMusicToken()
if len(lyricsPlusServers) == 0 || lyricsPlusServers[0] != "https://lyricsplus.prjktla.workers.dev" {
t.Fatalf("unexpected LyricsPlus server order = %#v", lyricsPlusServers)
}
paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}`
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
@@ -140,7 +261,7 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
case req.URL.Host == "beta.music.apple.com" && (req.URL.Path == "" || req.URL.Path == "/"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`<script src="/assets/index~test.js"></script>`)), Request: req}, nil
case req.URL.Host == "beta.music.apple.com" && req.URL.Path == "/assets/index~test.js":
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJhbGci.test";`)), Request: req}, nil
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJ0eXAiOiJKV1Q.eyJpc3MiOiJ0ZXN0.c2ln";`)), Request: req}, nil
case req.URL.Host == "amp-api.music.apple.com" && strings.Contains(req.URL.Path, "/v1/catalog/us/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"results":{"songs":{"data":[{"id":"apple-2"},{"id":"apple-1"}]}},"resources":{"songs":{"apple-2":{"attributes":{"name":"Other","artistName":"Other","durationInMillis":1000}},"apple-1":{"attributes":{"name":"Song","artistName":"Artist","albumName":"Album","durationInMillis":180000}}}}}`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
@@ -236,6 +357,12 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
if _, err := netease.SearchSong("", ""); err == nil {
t.Fatal("expected empty netease search error")
}
rateLimitedNetease := &NeteaseClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"msg":"操作频繁,请稍候再试","code":405,"message":"操作频繁,请稍候再试"}`)), Request: req}, nil
})}}
if _, err := rateLimitedNetease.SearchSong("Song", "Artist"); err == nil || !isLyricsProviderUnavailableError(err) {
t.Fatalf("expected unavailable netease rate-limit error, got %v", err)
}
qq := &QQMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
@@ -311,6 +438,9 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/api/search/multi"):
if got := req.URL.Query().Get("per_page"); got != "5" {
t.Fatalf("genius per_page = %q", got)
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"response":{"sections":[{"hits":[{"type":"song","result":{"title":"Song","primary_artist_names":"Artist","url":"https://genius.com/artist-song-lyrics"}}]}]}}`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/genius/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":false,"lyrics":"Genius line"}`)), Request: req}, nil
+68
View File
@@ -0,0 +1,68 @@
package gobackend
import (
"os"
"path/filepath"
"testing"
)
func TestEditM4AFreeformTextWritesISRCAndLabel(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "track.m4a")
ilst := buildM4ATextTag("\xa9nam", "Title")
if err := os.WriteFile(path, buildM4AFileWithIlst(ilst, true), 0600); err != nil {
t.Fatal(err)
}
if err := EditM4AFreeformText(path, map[string]string{
"isrc": "USRC17607839",
"label": "Some Label",
}); err != nil {
t.Fatalf("EditM4AFreeformText: %v", err)
}
meta, err := ReadM4ATags(path)
if err != nil {
t.Fatalf("ReadM4ATags: %v", err)
}
if meta.ISRC != "USRC17607839" {
t.Fatalf("ISRC = %q, want USRC17607839", meta.ISRC)
}
if meta.Label != "Some Label" {
t.Fatalf("Label = %q, want Some Label", meta.Label)
}
if meta.Title != "Title" {
t.Fatalf("Title = %q, want Title (existing tag must survive)", meta.Title)
}
}
func TestEditM4AFreeformTextReplacesExisting(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "track.m4a")
ilst := buildM4ATextTag("\xa9nam", "Title")
ilst = append(ilst, buildM4AFreeformAtom("ISRC", "OLDISRC00001")...)
ilst = append(ilst, buildM4AFreeformAtom("LABEL", "Old Label")...)
if err := os.WriteFile(path, buildM4AFileWithIlst(ilst, true), 0600); err != nil {
t.Fatal(err)
}
if err := EditM4AFreeformText(path, map[string]string{
"isrc": "NEWISRC00002",
"label": "",
}); err != nil {
t.Fatalf("EditM4AFreeformText: %v", err)
}
meta, err := ReadM4ATags(path)
if err != nil {
t.Fatalf("ReadM4ATags: %v", err)
}
if meta.ISRC != "NEWISRC00002" {
t.Fatalf("ISRC = %q, want NEWISRC00002", meta.ISRC)
}
if meta.Label != "" {
t.Fatalf("Label = %q, want empty (cleared)", meta.Label)
}
}
+213 -42
View File
@@ -6,7 +6,7 @@ import (
"fmt"
stdimage "image"
_ "image/gif"
_ "image/jpeg"
"image/jpeg"
_ "image/png"
"io"
"math"
@@ -71,11 +71,83 @@ func detectCoverMIME(coverPath string, coverData []byte) string {
return "image/jpeg"
}
// maxFlacPictureBytes keeps cover art below the 24-bit length field of a FLAC
// metadata block; go-flac silently truncates oversized blocks into a corrupt file.
const maxFlacPictureBytes = 16 * 1000 * 1000
// fitCoverForFlac returns cover bytes that fit inside a FLAC PICTURE block,
// re-encoding and downscaling when needed. Returns false if the data cannot be
// decoded as an image.
func fitCoverForFlac(coverData []byte) ([]byte, bool) {
if len(coverData) <= maxFlacPictureBytes {
return coverData, true
}
img, _, err := stdimage.Decode(bytes.NewReader(coverData))
if err != nil {
return nil, false
}
for _, quality := range []int{90, 80, 70, 60} {
if encoded, ok := encodeJPEGUnder(img, quality, maxFlacPictureBytes); ok {
return encoded, true
}
}
for _, maxDim := range []int{1500, 1200, 1000, 800} {
scaled := downscaleImage(img, maxDim)
if encoded, ok := encodeJPEGUnder(scaled, 85, maxFlacPictureBytes); ok {
return encoded, true
}
}
return nil, false
}
func encodeJPEGUnder(img stdimage.Image, quality, limit int) ([]byte, bool) {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
return nil, false
}
if buf.Len() > limit {
return nil, false
}
return buf.Bytes(), true
}
func downscaleImage(img stdimage.Image, maxDim int) stdimage.Image {
bounds := img.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width <= maxDim && height <= maxDim {
return img
}
scale := float64(maxDim) / float64(max(width, height))
newWidth := max(1, int(float64(width)*scale))
newHeight := max(1, int(float64(height)*scale))
dst := stdimage.NewRGBA(stdimage.Rect(0, 0, newWidth, newHeight))
for y := 0; y < newHeight; y++ {
srcY := bounds.Min.Y + int(float64(y)/scale)
for x := 0; x < newWidth; x++ {
srcX := bounds.Min.X + int(float64(x)/scale)
dst.Set(x, y, img.At(srcX, srcY))
}
}
return dst
}
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
if len(coverData) == 0 {
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
}
fitted, ok := fitCoverForFlac(coverData)
if !ok {
return flac.MetaDataBlock{}, fmt.Errorf("cover too large for FLAC picture block and could not be resized")
}
coverData = fitted
mime := detectCoverMIME(coverPath, coverData)
picture := &flacpicture.MetadataBlockPicture{
PictureType: flacpicture.PictureTypeFrontCover,
@@ -175,10 +247,11 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
picBlock, err := buildPictureBlock(coverPath, coverData)
if err != nil {
return fmt.Errorf("failed to create picture block: %w", err)
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
} else {
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
} else {
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
@@ -230,10 +303,11 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
picBlock, err := buildPictureBlock("", coverData)
if err != nil {
return fmt.Errorf("failed to create picture block: %w", err)
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
} else {
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
return f.Save(filePath)
@@ -906,6 +980,32 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".wav") {
meta, err := ReadWAVTags(filePath)
if err == nil && meta != nil {
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
}
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc") {
meta, err := ReadAIFFTags(filePath)
if err == nil && meta != nil {
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
}
return extractLyricsFromSidecarLRC(filePath)
}
return extractLyricsFromSidecarLRC(filePath)
}
@@ -1097,9 +1197,7 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
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 {
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
return ilst, nil
}
}
@@ -1107,9 +1205,7 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
// Path 2: moov > meta > ilst (no udta wrapper)
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 {
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
return ilst, nil
}
}
@@ -1117,6 +1213,26 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
}
// findIlstInMeta locates the ilst atom inside a meta atom, handling both
// layouts: ISO-BMFF (4-byte version/flags before the child atoms, written by
// FFmpeg's mp4 muxer) and QuickTime (no version/flags, written by the mov muxer
// used for AC-4 passthrough).
func findIlstInMeta(f *os.File, meta atomHeader, fileSize int64) (atomHeader, bool) {
// ISO-BMFF: skip the 4-byte version/flags that precede the child atoms.
isoStart := meta.offset + meta.headerSize + 4
isoSize := meta.size - meta.headerSize - 4
if ilst, ok, _ := findAtomInRange(f, isoStart, isoSize, "ilst", fileSize); ok {
return ilst, true
}
// QuickTime: child atoms begin immediately after the meta header.
qtStart := meta.offset + meta.headerSize
qtSize := meta.size - meta.headerSize
if ilst, ok, _ := findAtomInRange(f, qtStart, qtSize, "ilst", fileSize); ok {
return ilst, true
}
return atomHeader{}, false
}
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
payloadLen := dataAtom.size - dataAtom.headerSize - 8
@@ -1254,9 +1370,7 @@ func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
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 {
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
udtaCopy := udta
return m4aMetadataPath{
moov: moov,
@@ -1269,9 +1383,7 @@ func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
}
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 {
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
return m4aMetadataPath{
moov: moov,
meta: meta,
@@ -1406,6 +1518,51 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
return nil
}
remove := map[string]struct{}{
"REPLAYGAIN_TRACK_GAIN": {},
"REPLAYGAIN_TRACK_PEAK": {},
"REPLAYGAIN_ALBUM_GAIN": {},
"REPLAYGAIN_ALBUM_PEAK": {},
"ITUNNORM": {},
}
order := []string{
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
"iTunNORM",
}
tags := make([]m4aFreeformTag, 0, len(order))
for _, key := range order {
value := strings.TrimSpace(replayGainFields[key])
if value == "" {
continue
}
name := key
if key != "iTunNORM" {
name = strings.ToLower(key)
}
tags = append(tags, m4aFreeformTag{name: name, value: value})
}
return writeM4AFreeformTags(filePath, remove, tags)
}
type m4aFreeformTag struct {
name string
value string
}
// writeM4AFreeformTags rewrites the ilst atom in place: it drops every existing
// freeform ("----") atom whose uppercased name is in `remove`, then appends the
// supplied tags (empty values are skipped, which effectively clears the field).
// Atom sizes are fixed up along the ilst -> meta -> udta -> moov chain.
//
// FFmpeg's MP4 muxer only writes a fixed set of recognized keys to the ilst, so
// fields like ISRC and LABEL are silently dropped when written via -metadata.
// Writing them as iTunes freeform atoms natively is the only way they persist.
func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4aFreeformTag) error {
f, err := os.Open(filePath)
if err != nil {
return err
@@ -1419,6 +1576,13 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
path, err := findM4AMetadataPath(f, info.Size())
if err != nil {
// MOV-style containers (e.g. AC-4 passthrough) store tags as QuickTime
// atoms under udta with no iTunes meta>ilst structure. There is nowhere
// to write freeform tags, so skip gracefully instead of failing.
if strings.Contains(err.Error(), "ilst not found") {
GoLog("[Metadata] No iTunes ilst container; skipping freeform tags")
return nil
}
return err
}
@@ -1430,13 +1594,6 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
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())
@@ -1454,7 +1611,7 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
if header.typ == "----" {
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
if freeformErr == nil {
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
if _, ok := remove[strings.ToUpper(strings.TrimSpace(name))]; ok {
keep = false
}
}
@@ -1466,23 +1623,11 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
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 == "" {
for _, tag := range tags {
if strings.TrimSpace(tag.value) == "" {
continue
}
name := key
if key != "iTunNORM" {
name = strings.ToLower(key)
}
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
newBody = append(newBody, buildM4AFreeformAtom(tag.name, tag.value)...)
}
newIlst := buildM4AAtom("ilst", newBody)
@@ -1509,6 +1654,32 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
return os.WriteFile(filePath, updated, 0o644)
}
// EditM4AFreeformText writes ISRC and label tags into an M4A/MP4 file as iTunes
// freeform atoms. These keys are not part of FFmpeg's MP4 metadata key set, so
// they must be written natively for the values to actually persist. An empty
// value clears the corresponding tag. Other (recognized) tags are left intact.
func EditM4AFreeformText(filePath string, fields map[string]string) error {
_, hasISRC := fields["isrc"]
_, hasLabel := fields["label"]
if !hasISRC && !hasLabel {
return nil
}
remove := map[string]struct{}{}
tags := make([]m4aFreeformTag, 0, 2)
if hasISRC {
remove["ISRC"] = struct{}{}
tags = append(tags, m4aFreeformTag{name: "ISRC", value: strings.TrimSpace(fields["isrc"])})
}
if hasLabel {
remove["LABEL"] = struct{}{}
remove["ORGANIZATION"] = struct{}{}
tags = append(tags, m4aFreeformTag{name: "LABEL", value: strings.TrimSpace(fields["label"])})
}
return writeM4AFreeformTags(filePath, remove, tags)
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
+1 -1
View File
@@ -35,7 +35,7 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
if err == nil {
return file, nil
}
if strings.Contains(strings.ToLower(err.Error()), "permission denied") {
if os.IsPermission(err) {
return os.OpenFile(path, os.O_WRONLY, 0)
}
return nil, err
+959
View File
@@ -0,0 +1,959 @@
package gobackend
// WAV (RIFF) and AIFF/AIFC support: quality probing, tag reading/writing, and
// cover-art extraction. These containers are not handled by go-flac, so chunks
// are parsed/written by hand here.
//
// Tags are stored as an embedded ID3v2.4 tag (UTF-8): WAV uses a lowercase
// "id3 " chunk, AIFF uses an uppercase "ID3 " chunk. ID3v2.4 is chosen because
// the existing ID3 reader (parseID3v23Frames with version=4) reads synchsafe
// frame sizes and UTF-8 text, so anything we write is read back losslessly.
//
// Reading also recognises a WAV "LIST"/"INFO" block as a fallback for files
// that carry only RIFF INFO tags (common from other taggers).
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strconv"
"strings"
)
// WAVQuality / AIFFQuality mirror the other GetXQuality result shapes.
type WAVQuality struct {
SampleRate int
BitDepth int
Channels int
Duration int
}
const (
wavMaxMetaChunk = 16 * 1024 * 1024 // safety cap for buffering a metadata chunk
id3ChunkWAV = "id3 "
id3ChunkAIFF = "ID3 "
wavFormatPCM = 0x0001
wavFormatFloat = 0x0003
wavFormatExtensn = 0xFFFE
)
func putUint32(dst []byte, le bool, v uint32) {
if le {
binary.LittleEndian.PutUint32(dst, v)
} else {
binary.BigEndian.PutUint32(dst, v)
}
}
func readUint32(b []byte, le bool) uint32 {
if le {
return binary.LittleEndian.Uint32(b)
}
return binary.BigEndian.Uint32(b)
}
func synchsafeEncode(n int) []byte {
return []byte{
byte((n >> 21) & 0x7f),
byte((n >> 14) & 0x7f),
byte((n >> 7) & 0x7f),
byte(n & 0x7f),
}
}
func synchsafeDecode(b []byte) int {
if len(b) < 4 {
return 0
}
return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3])
}
// parseExtendedFloat80 decodes an 80-bit IEEE 754 extended float (used by the
// AIFF COMM chunk for the sample rate).
func parseExtendedFloat80(b []byte) float64 {
if len(b) < 10 {
return 0
}
sign := 1.0
if b[0]&0x80 != 0 {
sign = -1.0
}
exponent := int(b[0]&0x7f)<<8 | int(b[1])
var mantissa uint64
for i := 2; i < 10; i++ {
mantissa = mantissa<<8 | uint64(b[i])
}
if exponent == 0 && mantissa == 0 {
return 0
}
return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63))
}
type wavProbe struct {
sampleRate int
bitDepth int
channels int
byteRate int
dataSize int64
id3 []byte
info map[string]string
}
// streamProbeWAV walks the top-level RIFF chunks, buffering only the small
// metadata chunks (fmt/id3/LIST) and skipping the large data chunk.
func streamProbeWAV(f *os.File) (*wavProbe, error) {
header := make([]byte, 12)
if _, err := io.ReadFull(f, header); err != nil {
return nil, err
}
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
return nil, fmt.Errorf("not a WAVE file")
}
p := &wavProbe{info: map[string]string{}}
hdr := make([]byte, 8)
for {
if _, err := io.ReadFull(f, hdr); err != nil {
break
}
id := string(hdr[0:4])
size := readUint32(hdr[4:8], true)
pad := int64(size) & 1
switch id {
case "fmt ":
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err != nil {
return p, nil
}
if len(buf) >= 16 {
format := binary.LittleEndian.Uint16(buf[0:2])
p.channels = int(binary.LittleEndian.Uint16(buf[2:4]))
p.sampleRate = int(binary.LittleEndian.Uint32(buf[4:8]))
p.byteRate = int(binary.LittleEndian.Uint32(buf[8:12]))
p.bitDepth = int(binary.LittleEndian.Uint16(buf[14:16]))
if format == wavFormatExtensn && len(buf) >= 26 {
// Valid bits per sample lives in the extension; the real
// PCM format tag is in the GUID, but bitDepth from the
// container field is sufficient for display.
if vb := int(binary.LittleEndian.Uint16(buf[18:20])); vb > 0 {
p.bitDepth = vb
}
}
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
case "data":
p.dataSize = int64(size)
f.Seek(int64(size)+pad, io.SeekCurrent)
case id3ChunkWAV, "ID3 ":
if size > 0 && size <= wavMaxMetaChunk {
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err == nil {
p.id3 = buf
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
} else {
f.Seek(int64(size)+pad, io.SeekCurrent)
}
case "LIST":
if size > 0 && size <= wavMaxMetaChunk {
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err == nil {
parseRIFFInfo(buf, p.info)
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
} else {
f.Seek(int64(size)+pad, io.SeekCurrent)
}
default:
f.Seek(int64(size)+pad, io.SeekCurrent)
}
}
return p, nil
}
// parseRIFFInfo reads a LIST/INFO block ("INFO" + sub-chunks like INAM, IART).
func parseRIFFInfo(buf []byte, out map[string]string) {
if len(buf) < 4 || string(buf[0:4]) != "INFO" {
return
}
pos := 4
for pos+8 <= len(buf) {
id := string(buf[pos : pos+4])
size := int(binary.LittleEndian.Uint32(buf[pos+4 : pos+8]))
pos += 8
if size <= 0 || pos+size > len(buf) {
break
}
val := strings.TrimRight(string(buf[pos:pos+size]), "\x00")
out[id] = strings.TrimSpace(val)
pos += size
if size&1 == 1 {
pos++
}
}
}
func wavMetadataFromProbe(p *wavProbe) *AudioMetadata {
if p == nil {
return nil
}
if len(p.id3) > 0 {
if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil &&
(meta.Title != "" || meta.Artist != "" || meta.Album != "") {
return meta
}
}
if len(p.info) > 0 {
meta := &AudioMetadata{
Title: p.info["INAM"],
Artist: p.info["IART"],
Album: p.info["IPRD"],
Genre: cleanGenre(p.info["IGNR"]),
Date: p.info["ICRD"],
Comment: p.info["ICMT"],
Copyright: p.info["ICOP"],
Composer: p.info["IMUS"],
}
if n, err := strconv.Atoi(strings.TrimSpace(p.info["ITRK"])); err == nil {
meta.TrackNumber = n
}
if meta.Date != "" && len(meta.Date) >= 4 {
meta.Year = meta.Date[:4]
}
if meta.Title != "" || meta.Artist != "" || meta.Album != "" {
return meta
}
}
return nil
}
// GetWAVQuality probes PCM parameters and computes duration from the data size.
func GetWAVQuality(filePath string) (*WAVQuality, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
p, err := streamProbeWAV(f)
if err != nil {
return nil, err
}
q := &WAVQuality{
SampleRate: p.sampleRate,
BitDepth: p.bitDepth,
Channels: p.channels,
}
if p.byteRate > 0 && p.dataSize > 0 {
q.Duration = int(p.dataSize / int64(p.byteRate))
} else if p.sampleRate > 0 && p.channels > 0 && p.bitDepth > 0 && p.dataSize > 0 {
bytesPerSec := int64(p.sampleRate * p.channels * p.bitDepth / 8)
if bytesPerSec > 0 {
q.Duration = int(p.dataSize / bytesPerSec)
}
}
return q, nil
}
// ReadWAVTags reads tags from a WAV file (ID3 chunk preferred, RIFF INFO fallback).
func ReadWAVTags(filePath string) (*AudioMetadata, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
p, err := streamProbeWAV(f)
if err != nil {
return nil, err
}
meta := wavMetadataFromProbe(p)
if meta == nil {
return nil, fmt.Errorf("no WAV tags found")
}
return meta, nil
}
type aiffProbe struct {
sampleRate int
bitDepth int
channels int
numFrames int64
id3 []byte
nameChunk string
authChunk string
annoChunk string
copyrightChunk string
}
func streamProbeAIFF(f *os.File) (*aiffProbe, error) {
header := make([]byte, 12)
if _, err := io.ReadFull(f, header); err != nil {
return nil, err
}
form := string(header[8:12])
if string(header[0:4]) != "FORM" || (form != "AIFF" && form != "AIFC") {
return nil, fmt.Errorf("not an AIFF file")
}
p := &aiffProbe{}
hdr := make([]byte, 8)
for {
if _, err := io.ReadFull(f, hdr); err != nil {
break
}
id := string(hdr[0:4])
size := readUint32(hdr[4:8], false)
pad := int64(size) & 1
switch id {
case "COMM":
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err != nil {
return p, nil
}
if len(buf) >= 18 {
p.channels = int(binary.BigEndian.Uint16(buf[0:2]))
p.numFrames = int64(binary.BigEndian.Uint32(buf[2:6]))
p.bitDepth = int(binary.BigEndian.Uint16(buf[6:8]))
p.sampleRate = int(parseExtendedFloat80(buf[8:18]) + 0.5)
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
case id3ChunkAIFF, "id3 ":
if size > 0 && size <= wavMaxMetaChunk {
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err == nil {
p.id3 = buf
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
} else {
f.Seek(int64(size)+pad, io.SeekCurrent)
}
case "NAME", "AUTH", "ANNO", "(c) ":
if size > 0 && size <= wavMaxMetaChunk {
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err == nil {
val := strings.TrimRight(strings.TrimSpace(string(buf)), "\x00")
switch id {
case "NAME":
p.nameChunk = val
case "AUTH":
p.authChunk = val
case "ANNO":
p.annoChunk = val
case "(c) ":
p.copyrightChunk = val
}
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
} else {
f.Seek(int64(size)+pad, io.SeekCurrent)
}
default:
f.Seek(int64(size)+pad, io.SeekCurrent)
}
}
return p, nil
}
func aiffMetadataFromProbe(p *aiffProbe) *AudioMetadata {
if p == nil {
return nil
}
if len(p.id3) > 0 {
if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil &&
(meta.Title != "" || meta.Artist != "" || meta.Album != "") {
return meta
}
}
if p.nameChunk != "" || p.authChunk != "" {
meta := &AudioMetadata{
Title: p.nameChunk,
Artist: p.authChunk,
Comment: p.annoChunk,
Copyright: p.copyrightChunk,
}
return meta
}
return nil
}
// GetAIFFQuality probes PCM parameters and computes duration from frame count.
func GetAIFFQuality(filePath string) (*WAVQuality, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
p, err := streamProbeAIFF(f)
if err != nil {
return nil, err
}
q := &WAVQuality{
SampleRate: p.sampleRate,
BitDepth: p.bitDepth,
Channels: p.channels,
}
if p.sampleRate > 0 && p.numFrames > 0 {
q.Duration = int(p.numFrames / int64(p.sampleRate))
}
return q, nil
}
// ReadAIFFTags reads tags from an AIFF file (ID3 chunk preferred, AIFF text chunks fallback).
func ReadAIFFTags(filePath string) (*AudioMetadata, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
p, err := streamProbeAIFF(f)
if err != nil {
return nil, err
}
meta := aiffMetadataFromProbe(p)
if meta == nil {
return nil, fmt.Errorf("no AIFF tags found")
}
return meta, nil
}
// readID3v2FromBytes parses an in-memory ID3v2 tag (the contents of a WAV "id3 "
// or AIFF "ID3 " chunk) by reusing the existing frame parsers.
func readID3v2FromBytes(data []byte) (*AudioMetadata, error) {
if len(data) < 10 || string(data[0:3]) != "ID3" {
return nil, fmt.Errorf("no ID3v2 header")
}
majorVersion := data[3]
flags := data[5]
unsync := (flags & 0x80) != 0
extendedHeader := (flags & 0x40) != 0
footerPresent := (flags & 0x10) != 0
size := synchsafeDecode(data[6:10])
if size <= 0 || 10+size > len(data) {
size = len(data) - 10
}
tagData := data[10 : 10+size]
if footerPresent && len(tagData) >= 10 {
footerStart := len(tagData) - 10
if footerStart >= 0 && string(tagData[footerStart:footerStart+3]) == "3DI" {
tagData = tagData[:footerStart]
}
}
if extendedHeader {
if skip := extendedHeaderSize(tagData, majorVersion); skip > 0 && skip < len(tagData) {
tagData = tagData[skip:]
}
}
metadata := &AudioMetadata{}
if majorVersion == 2 {
parseID3v22Frames(tagData, metadata, unsync)
} else {
parseID3v23Frames(tagData, metadata, majorVersion, unsync)
}
return metadata, nil
}
// extractAPICFromID3 returns the first embedded picture (APIC/PIC) and its MIME.
func extractAPICFromID3(tag []byte) ([]byte, string) {
if len(tag) < 10 || string(tag[0:3]) != "ID3" {
return nil, ""
}
ver := tag[3]
size := synchsafeDecode(tag[6:10])
if size <= 0 || 10+size > len(tag) {
size = len(tag) - 10
}
data := tag[10 : 10+size]
pos := 0
for {
if ver == 2 {
if pos+6 > len(data) || data[pos] == 0 {
break
}
id := string(data[pos : pos+3])
fsz := int(data[pos+3])<<16 | int(data[pos+4])<<8 | int(data[pos+5])
if fsz <= 0 || pos+6+fsz > len(data) {
break
}
if id == "PIC" {
return parseAPICFrame(data[pos+6:pos+6+fsz], ver)
}
pos += 6 + fsz
continue
}
if pos+10 > len(data) || data[pos] == 0 {
break
}
id := string(data[pos : pos+4])
var fsz int
if ver == 4 {
fsz = synchsafeDecode(data[pos+4 : pos+8])
} else {
fsz = int(binary.BigEndian.Uint32(data[pos+4 : pos+8]))
}
if fsz <= 0 || pos+10+fsz > len(data) {
break
}
if id == "APIC" {
return parseAPICFrame(data[pos+10:pos+10+fsz], ver)
}
pos += 10 + fsz
}
return nil, ""
}
// buildID3v24Tag builds a UTF-8 ID3v2.4 tag from metadata plus optional cover.
func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []byte {
var frames bytes.Buffer
writeFrame := func(id string, payload []byte) {
frames.WriteString(id)
frames.Write(synchsafeEncode(len(payload)))
frames.Write([]byte{0, 0})
frames.Write(payload)
}
writeText := func(id, val string) {
if strings.TrimSpace(val) == "" {
return
}
payload := append([]byte{0x03}, []byte(val)...)
writeFrame(id, payload)
}
writeText("TIT2", meta.Title)
writeText("TPE1", meta.Artist)
writeText("TALB", meta.Album)
writeText("TPE2", meta.AlbumArtist)
writeText("TCON", meta.Genre)
writeText("TCOM", meta.Composer)
writeText("TPUB", meta.Label)
writeText("TCOP", meta.Copyright)
writeText("TSRC", meta.ISRC)
date := meta.Date
if date == "" {
date = meta.Year
}
writeText("TDRC", date)
if meta.TrackNumber > 0 {
if meta.TotalTracks > 0 {
writeText("TRCK", fmt.Sprintf("%d/%d", meta.TrackNumber, meta.TotalTracks))
} else {
writeText("TRCK", strconv.Itoa(meta.TrackNumber))
}
}
if meta.DiscNumber > 0 {
if meta.TotalDiscs > 0 {
writeText("TPOS", fmt.Sprintf("%d/%d", meta.DiscNumber, meta.TotalDiscs))
} else {
writeText("TPOS", strconv.Itoa(meta.DiscNumber))
}
}
if strings.TrimSpace(meta.Comment) != "" {
// COMM: encoding + language(3) + short desc(null) + text
payload := []byte{0x03}
payload = append(payload, []byte("eng")...)
payload = append(payload, 0x00) // empty description
payload = append(payload, []byte(meta.Comment)...)
writeFrame("COMM", payload)
}
if strings.TrimSpace(meta.Lyrics) != "" {
payload := []byte{0x03}
payload = append(payload, []byte("eng")...)
payload = append(payload, 0x00)
payload = append(payload, []byte(meta.Lyrics)...)
writeFrame("USLT", payload)
}
// ReplayGain as TXXX (description\0value), UTF-8.
writeTXXX := func(desc, val string) {
if strings.TrimSpace(val) == "" {
return
}
payload := []byte{0x03}
payload = append(payload, []byte(desc)...)
payload = append(payload, 0x00)
payload = append(payload, []byte(val)...)
writeFrame("TXXX", payload)
}
writeTXXX("REPLAYGAIN_TRACK_GAIN", meta.ReplayGainTrackGain)
writeTXXX("REPLAYGAIN_TRACK_PEAK", meta.ReplayGainTrackPeak)
writeTXXX("REPLAYGAIN_ALBUM_GAIN", meta.ReplayGainAlbumGain)
writeTXXX("REPLAYGAIN_ALBUM_PEAK", meta.ReplayGainAlbumPeak)
if len(coverData) > 0 {
if strings.TrimSpace(coverMIME) == "" {
coverMIME = "image/jpeg"
}
// APIC: encoding + mime(null) + picture-type(0x03 front) + desc(null) + data
payload := []byte{0x03}
payload = append(payload, []byte(coverMIME)...)
payload = append(payload, 0x00)
payload = append(payload, 0x03)
payload = append(payload, 0x00)
payload = append(payload, coverData...)
writeFrame("APIC", payload)
}
body := frames.Bytes()
var out bytes.Buffer
out.WriteString("ID3")
out.Write([]byte{0x04, 0x00}) // v2.4.0
out.WriteByte(0x00) // flags
out.Write(synchsafeEncode(len(body)))
out.Write(body)
return out.Bytes()
}
// writeID3Chunk rewrites filePath, replacing any existing tag chunk (chunkID,
// matched case-insensitively) with a fresh ID3v2.4 chunk appended at the end.
// The audio data and all other chunks are preserved; container size is patched.
func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) error {
in, err := os.Open(filePath)
if err != nil {
return err
}
defer in.Close()
header := make([]byte, 12)
if _, err := io.ReadFull(in, header); err != nil {
return err
}
if string(header[0:4]) != expectMagic {
return fmt.Errorf("unexpected container magic %q", string(header[0:4]))
}
tmpPath := filePath + ".tagtmp"
out, err := os.Create(tmpPath)
if err != nil {
return err
}
cleanup := func() {
out.Close()
os.Remove(tmpPath)
}
if _, err := out.Write(header); err != nil {
cleanup()
return err
}
var bodyLen int64 = 4 // the 4-byte form type after the size field
hdr := make([]byte, 8)
for {
n, rerr := io.ReadFull(in, hdr)
if n < 8 {
break
}
if rerr != nil {
break
}
id := string(hdr[0:4])
size := readUint32(hdr[4:8], le)
pad := int64(size) & 1
if strings.EqualFold(id, chunkID) {
if _, err := in.Seek(int64(size)+pad, io.SeekCurrent); err != nil {
cleanup()
return err
}
continue
}
if _, err := out.Write(hdr); err != nil {
cleanup()
return err
}
if _, err := io.CopyN(out, in, int64(size)+pad); err != nil {
cleanup()
return err
}
bodyLen += 8 + int64(size) + pad
}
newSize := len(id3)
chunkHdr := make([]byte, 8)
copy(chunkHdr[0:4], chunkID)
putUint32(chunkHdr[4:8], le, uint32(newSize))
if _, err := out.Write(chunkHdr); err != nil {
cleanup()
return err
}
if _, err := out.Write(id3); err != nil {
cleanup()
return err
}
if newSize&1 == 1 {
if _, err := out.Write([]byte{0}); err != nil {
cleanup()
return err
}
}
bodyLen += 8 + int64(newSize) + int64(newSize&1)
// Patch the container size field (bytes 4..8).
sizeBuf := make([]byte, 4)
putUint32(sizeBuf, le, uint32(bodyLen))
if _, err := out.WriteAt(sizeBuf, 4); err != nil {
cleanup()
return err
}
if err := out.Close(); err != nil {
os.Remove(tmpPath)
return err
}
in.Close()
return os.Rename(tmpPath, filePath)
}
func loadCoverForTag(fields map[string]string) ([]byte, string) {
coverPath := strings.TrimSpace(fields["cover_path"])
if coverPath == "" {
return nil, ""
}
data, err := os.ReadFile(coverPath)
if err != nil || len(data) == 0 {
return nil, ""
}
mime := "image/jpeg"
if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
mime = "image/png"
}
return data, mime
}
func audioMetadataFromEditFields(fields map[string]string) *AudioMetadata {
atoi := func(k string) int {
n := 0
if v, ok := fields[k]; ok && strings.TrimSpace(v) != "" {
fmt.Sscanf(strings.TrimSpace(v), "%d", &n)
}
return n
}
return &AudioMetadata{
Title: fields["title"],
Artist: fields["artist"],
Album: fields["album"],
AlbumArtist: fields["album_artist"],
Date: fields["date"],
TrackNumber: atoi("track_number"),
TotalTracks: atoi("track_total"),
DiscNumber: atoi("disc_number"),
TotalDiscs: atoi("disc_total"),
ISRC: fields["isrc"],
Lyrics: fields["lyrics"],
Genre: fields["genre"],
Label: fields["label"],
Copyright: fields["copyright"],
Composer: fields["composer"],
Comment: fields["comment"],
ReplayGainTrackGain: fields["replaygain_track_gain"],
ReplayGainTrackPeak: fields["replaygain_track_peak"],
ReplayGainAlbumGain: fields["replaygain_album_gain"],
ReplayGainAlbumPeak: fields["replaygain_album_peak"],
}
}
// mergeWAVEditFields merges edit fields onto existing tags so untouched fields
// (and cover art, when no new cover is provided) are preserved.
func mergeEditFieldsOntoExisting(existing *AudioMetadata, fields map[string]string) *AudioMetadata {
meta := audioMetadataFromEditFields(fields)
if existing == nil {
return meta
}
// Only overwrite fields that are present as keys in the edit set; otherwise
// keep the existing value. An empty value with the key present clears it.
keep := func(key, newVal, oldVal string) string {
if _, ok := fields[key]; ok {
return newVal
}
return oldVal
}
meta.Title = keep("title", meta.Title, existing.Title)
meta.Artist = keep("artist", meta.Artist, existing.Artist)
meta.Album = keep("album", meta.Album, existing.Album)
meta.AlbumArtist = keep("album_artist", meta.AlbumArtist, existing.AlbumArtist)
meta.Genre = keep("genre", meta.Genre, existing.Genre)
meta.Composer = keep("composer", meta.Composer, existing.Composer)
meta.Label = keep("label", meta.Label, existing.Label)
meta.Copyright = keep("copyright", meta.Copyright, existing.Copyright)
meta.ISRC = keep("isrc", meta.ISRC, existing.ISRC)
meta.Lyrics = keep("lyrics", meta.Lyrics, existing.Lyrics)
meta.Comment = keep("comment", meta.Comment, existing.Comment)
meta.Date = keep("date", meta.Date, existing.Date)
if _, ok := fields["track_number"]; !ok {
meta.TrackNumber = existing.TrackNumber
}
if _, ok := fields["track_total"]; !ok {
meta.TotalTracks = existing.TotalTracks
}
if _, ok := fields["disc_number"]; !ok {
meta.DiscNumber = existing.DiscNumber
}
if _, ok := fields["disc_total"]; !ok {
meta.TotalDiscs = existing.TotalDiscs
}
if _, ok := fields["replaygain_track_gain"]; !ok {
meta.ReplayGainTrackGain = existing.ReplayGainTrackGain
}
if _, ok := fields["replaygain_track_peak"]; !ok {
meta.ReplayGainTrackPeak = existing.ReplayGainTrackPeak
}
if _, ok := fields["replaygain_album_gain"]; !ok {
meta.ReplayGainAlbumGain = existing.ReplayGainAlbumGain
}
if _, ok := fields["replaygain_album_peak"]; !ok {
meta.ReplayGainAlbumPeak = existing.ReplayGainAlbumPeak
}
return meta
}
// WriteWAVTags writes/merges tags into a WAV file's "id3 " chunk.
func WriteWAVTags(filePath string, fields map[string]string) error {
existing, _ := ReadWAVTags(filePath)
meta := mergeEditFieldsOntoExisting(existing, fields)
coverData, coverMIME := loadCoverForTag(fields)
if coverData == nil {
// Preserve an existing embedded cover when no new one is supplied.
if f, err := os.Open(filePath); err == nil {
if p, perr := streamProbeWAV(f); perr == nil && len(p.id3) > 0 {
coverData, coverMIME = extractAPICFromID3(p.id3)
}
f.Close()
}
}
tag := buildID3v24Tag(meta, coverData, coverMIME)
return writeID3Chunk(filePath, "RIFF", id3ChunkWAV, true, tag)
}
// WriteAIFFTags writes/merges tags into an AIFF file's "ID3 " chunk.
func WriteAIFFTags(filePath string, fields map[string]string) error {
existing, _ := ReadAIFFTags(filePath)
meta := mergeEditFieldsOntoExisting(existing, fields)
coverData, coverMIME := loadCoverForTag(fields)
if coverData == nil {
if f, err := os.Open(filePath); err == nil {
if p, perr := streamProbeAIFF(f); perr == nil && len(p.id3) > 0 {
coverData, coverMIME = extractAPICFromID3(p.id3)
}
f.Close()
}
}
tag := buildID3v24Tag(meta, coverData, coverMIME)
return writeID3Chunk(filePath, "FORM", id3ChunkAIFF, false, tag)
}
func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil {
applyAudioMetadataToScan(metadata, result)
}
if quality, err := GetWAVQuality(filePath); err == nil && quality != nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.Duration = quality.Duration
}
result.Bitrate = 0 // lossless PCM
result.Format = "wav"
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanAIFFFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
if metadata, err := ReadAIFFTags(filePath); err == nil && metadata != nil {
applyAudioMetadataToScan(metadata, result)
}
if quality, err := GetAIFFQuality(filePath); err == nil && quality != nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.Duration = quality.Duration
}
result.Bitrate = 0 // lossless PCM
result.Format = "aiff"
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func applyAudioMetadataToScan(metadata *AudioMetadata, result *LibraryScanResult) {
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
if metadata.Date != "" {
result.ReleaseDate = metadata.Date
} else {
result.ReleaseDate = metadata.Year
}
result.Genre = metadata.Genre
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
}
// extractWAVAIFFCover returns embedded cover art (from the ID3 chunk) for a
// WAV or AIFF file, or an error when none is present.
func extractWAVAIFFCover(filePath string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
f, err := os.Open(filePath)
if err != nil {
return nil, "", err
}
defer f.Close()
var id3 []byte
switch ext {
case ".aiff", ".aif", ".aifc":
if p, perr := streamProbeAIFF(f); perr == nil {
id3 = p.id3
}
default:
if p, perr := streamProbeWAV(f); perr == nil {
id3 = p.id3
}
}
if len(id3) == 0 {
return nil, "", fmt.Errorf("no embedded cover")
}
data, mime := extractAPICFromID3(id3)
if len(data) == 0 {
return nil, "", fmt.Errorf("no embedded cover")
}
return data, mime, nil
}
+95 -19
View File
@@ -1,6 +1,6 @@
import Flutter
import UIKit
import Gobackend // Import Go framework
import Gobackend
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -17,9 +17,16 @@ import Gobackend // Import Go framework
private var libraryScanProgressTimer: DispatchSourceTimer?
private var libraryScanProgressEventSink: FlutterEventSink?
private var lastLibraryScanProgressPayload: String?
private var backendChannel: FlutterMethodChannel?
private var pendingSessionGrantEvents: [[String: Any]] = []
/// Currently accessed security-scoped URL for library folder
private var activeSecurityScopedURL: URL?
/// Whether a download queue is active; while true a background task is
/// started on each background entry to extend execution time. Main-thread only.
private var downloadsActive = false
private var downloadBackgroundTask: UIBackgroundTaskIdentifier = .invalid
override func application(
_ application: UIApplication,
@@ -34,6 +41,14 @@ import Gobackend // Import Go framework
name: CHANNEL,
binaryMessenger: controller.binaryMessenger
)
backendChannel = channel
if !pendingSessionGrantEvents.isEmpty {
let events = pendingSessionGrantEvents
pendingSessionGrantEvents.removeAll()
for event in events {
channel.invokeMethod("extensionSessionGrantCompleted", arguments: event)
}
}
let downloadProgressEvents = FlutterEventChannel(
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
binaryMessenger: controller.binaryMessenger
@@ -78,20 +93,25 @@ import Gobackend // Import Go framework
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
/// PKCE OAuth return URL: spotiflac://callback?code=...&state=<extension_id>
/// Extension return URLs:
/// - OAuth: spotiflac://callback?code=...&state=<extension_id>
/// - Signed session: spotiflac://session-grant?grant=...&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 isSessionGrant = host == "session-grant"
let ok =
host == "callback" || host == "spotify-callback" || path.contains("callback")
isSessionGrant || 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 == (isSessionGrant ? "grant" : "code") }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ??
q.first { $0.name == "code" }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ?? ""
let state =
@@ -104,16 +124,37 @@ import Gobackend // Import Go framework
}
streamQueue.async {
var err: NSError?
GobackendSetExtensionAuthCodeByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
if isSessionGrant {
GobackendSetExtensionSessionGrantByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeGrant", &err)
} else {
GobackendSetExtensionAuthCodeByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
}
if let err = err {
NSLog(
"SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)")
"SpotiFLAC: Extension callback complete failed: \(err.localizedDescription)")
} else if isSessionGrant {
DispatchQueue.main.async { [weak self] in
self?.notifySessionGrantCompleted(extensionId: state)
}
}
}
return true
}
private func notifySessionGrantCompleted(extensionId: String) {
let payload: [String: Any] = [
"extension_id": extensionId,
"success": true,
]
if let channel = backendChannel {
channel.invokeMethod("extensionSessionGrantCompleted", arguments: payload)
} else {
pendingSessionGrantEvents.append(payload)
}
}
override func application(
_ app: UIApplication,
open url: URL,
@@ -233,6 +274,20 @@ import Gobackend // Import Go framework
}
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "beginBackgroundDownloadTask":
downloadsActive = true
result(nil)
return
case "endBackgroundDownloadTask":
downloadsActive = false
endBackgroundDownloadTask()
result(nil)
return
default:
break
}
DispatchQueue.global(qos: .userInitiated).async {
do {
let response = try self.invokeGoMethod(call: call)
@@ -246,6 +301,34 @@ import Gobackend // Import Go framework
}
}
}
override func applicationDidEnterBackground(_ application: UIApplication) {
super.applicationDidEnterBackground(application)
if downloadsActive {
beginBackgroundDownloadTask()
}
}
override func applicationWillEnterForeground(_ application: UIApplication) {
super.applicationWillEnterForeground(application)
endBackgroundDownloadTask()
}
private func beginBackgroundDownloadTask() {
if downloadBackgroundTask != .invalid { return }
downloadBackgroundTask = UIApplication.shared.beginBackgroundTask(
withName: "SpotiFLACDownloads"
) { [weak self] in
self?.endBackgroundDownloadTask()
}
}
private func endBackgroundDownloadTask() {
if downloadBackgroundTask != .invalid {
UIApplication.shared.endBackgroundTask(downloadBackgroundTask)
downloadBackgroundTask = .invalid
}
}
private func invokeGoMethod(call: FlutterMethodCall) throws -> Any? {
var error: NSError?
@@ -310,6 +393,12 @@ import Gobackend // Import Go framework
let insecureTLS = args["insecure_tls"] as? Bool ?? false
GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
return nil
case "setAllowPrivateNetwork":
let args = call.arguments as! [String: Any]
let allowed = args["allowed"] as? Bool ?? false
GobackendSetAllowPrivateNetwork(allowed)
return nil
case "checkDuplicate":
let args = call.arguments as! [String: Any]
@@ -543,7 +632,6 @@ import Gobackend // Import Go framework
GobackendClearTrackCache()
return nil
// Log methods
case "getLogs":
let response = GobackendGetLogs()
return response
@@ -568,7 +656,6 @@ import Gobackend // Import Go framework
GobackendSetLoggingEnabled(enabled)
return nil
// Extension System methods
case "initExtensionSystem":
let args = call.arguments as! [String: Any]
let extensionsDir = args["extensions_dir"] as! String
@@ -733,7 +820,6 @@ import Gobackend // Import Go framework
GobackendCleanupExtensions()
return nil
// Extension Auth API
case "getExtensionPendingAuth":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -774,7 +860,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Extension FFmpeg API
case "getPendingFFmpegCommand":
let args = call.arguments as! [String: Any]
let commandId = args["command_id"] as! String
@@ -796,7 +881,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Extension Custom Search API
case "customSearchWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -818,7 +902,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Extension URL Handler API
case "handleURLWithExtension":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
@@ -837,7 +920,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Extension Post-Processing API
case "runPostProcessing":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
@@ -859,7 +941,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Extension Store
case "initExtensionStore":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
@@ -917,7 +998,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return nil
// Extension Home Feed API
case "getExtensionHomeFeed":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -933,7 +1013,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Local Library Scanning
case "setLibraryCoverCacheDir":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
@@ -970,7 +1049,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// iOS Security-Scoped Bookmark for Local Library
case "resolveIosBookmark":
let args = call.arguments as! [String: Any]
let bookmarkBase64 = args["bookmark"] as! String
@@ -990,7 +1068,6 @@ import Gobackend // Import Go framework
let path = args["path"] as! String
return try createIosBookmarkFromPath(path)
// Lyrics Provider Settings
case "setLyricsProviders":
let args = call.arguments as! [String: Any]
let providersJson = args["providers_json"] as? String ?? "[]"
@@ -1020,7 +1097,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// CUE Sheet Parsing
case "parseCueSheet":
let args = call.arguments as! [String: Any]
let cuePath = args["cue_path"] as! String
+11
View File
@@ -7,6 +7,7 @@ import 'package:spotiflac_android/screens/main_shell.dart';
import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/screens/tutorial_screen.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/app_navigation_service.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
@@ -28,6 +29,7 @@ final _routerProvider = Provider<GoRouter>((ref) {
}
return GoRouter(
navigatorKey: AppNavigationService.rootNavigatorKey,
initialLocation: initialLocation,
routes: [
GoRoute(path: '/', builder: (context, state) => const MainShell()),
@@ -114,6 +116,15 @@ class SpotiFLACApp extends ConsumerWidget {
scrollBehavior: scrollBehavior,
themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut,
// Treat the display as one continuous surface so bottom sheets and
// dialogs stay centered on large/foldable devices.
builder: (context, child) {
final mediaQuery = MediaQuery.of(context);
return MediaQuery(
data: mediaQuery.copyWith(displayFeatures: const []),
child: child ?? const SizedBox.shrink(),
);
},
routerConfig: router,
locale: locale,
localeResolutionCallback: (deviceLocale, supportedLocales) {
+2 -2
View File
@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
class AppInfo {
static const String version = '4.5.6';
static const String buildNumber = '133';
static const String version = '4.7.1';
static const String buildNumber = '137';
static const String fullVersion = '$version+$buildNumber';
static String get displayVersion => kDebugMode ? 'Internal' : version;
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
+554 -29
View File
@@ -142,7 +142,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => 'Auto Fallback';
@@ -155,10 +155,12 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsUseExtensionProviders => 'Use Extension Providers';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
String get optionsUseExtensionProvidersOn =>
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
String get optionsUseExtensionProvidersOff =>
'Extension providers are required';
@override
String get optionsEmbedLyrics => 'Embed Lyrics';
@@ -185,6 +187,43 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@@ -206,21 +245,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@override
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
@override
String optionsConcurrentParallel(int count) {
return '$count parallel downloads';
}
@override
String get optionsConcurrentWarning =>
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Repo';
@@ -385,11 +409,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!';
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -579,6 +603,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -953,7 +986,7 @@ class AppLocalizationsEn extends AppLocalizations {
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => 'Extension';
@@ -1346,10 +1379,11 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => 'Author';
@@ -1523,7 +1557,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@@ -1567,6 +1601,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -1910,7 +1948,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.';
@override
String libraryTracksUnit(int count) {
@@ -2093,7 +2131,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2791,7 +2829,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2833,6 +2871,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@@ -2856,6 +2898,164 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -2985,13 +3185,24 @@ class AppLocalizationsEn extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select Tidal or Qobuz to enable this option';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz to choose audio quality';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@@ -4245,4 +4456,318 @@ class AppLocalizationsEn extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+606 -76
View File
@@ -126,7 +126,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get optionsPrimaryProviderSubtitle =>
'Service used when searching by track name.';
'Service used for searching by track or album name';
@override
String optionsUsingExtension(String extensionName) {
@@ -142,7 +142,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => 'Auto Fallback';
@@ -155,17 +155,19 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsUseExtensionProviders => 'Use Extension Providers';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
String get optionsUseExtensionProvidersOn =>
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
String get optionsUseExtensionProvidersOff =>
'Extension providers are required';
@override
String get optionsEmbedLyrics => 'Embed Lyrics';
@override
String get optionsEmbedLyricsSubtitle =>
'Embed synced lyrics into FLAC files';
'Save synced lyrics alongside your downloaded tracks';
@override
String get optionsMaxQualityCover => 'Max Quality Cover';
@@ -185,6 +187,43 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@@ -206,21 +245,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@override
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
@override
String optionsConcurrentParallel(int count) {
return '$count parallel downloads';
}
@override
String get optionsConcurrentWarning =>
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Repo';
@@ -385,11 +409,11 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!';
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -579,6 +603,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -953,7 +986,7 @@ class AppLocalizationsHi extends AppLocalizations {
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => 'Extension';
@@ -1121,10 +1154,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get settingsAppearanceSubtitle => 'Theme, colors, display';
@override
String get settingsDownloadSubtitle => 'Service, quality, filename format';
String get settingsDownloadSubtitle => 'Service, quality, fallback';
@override
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
@override
String get settingsExtensionsSubtitle => 'Manage download providers';
@@ -1346,10 +1379,11 @@ class AppLocalizationsHi extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => 'Author';
@@ -1523,7 +1557,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@@ -1567,6 +1601,10 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2093,7 +2131,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2423,7 +2461,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2776,14 +2814,14 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
'Folder named after Album Artist tag';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
'Folder named after Track Artist tag';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
@override
String get lyricsProvidersDescription =>
@@ -2791,7 +2829,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2833,6 +2871,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@@ -2851,10 +2893,168 @@ class AppLocalizationsHi extends AppLocalizations {
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
String get settingsDonate => 'Support Development';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -2914,20 +3114,20 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
'Choose where to save your downloaded tracks';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
String get storageModeAppFolder => 'App Folder (Recommended)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
String get storageModeAppFolderSubtitle =>
'Saves to Music/SpotiFLAC by default';
@override
String get storageModeSaf => 'SAF folder';
String get storageModeSaf => 'Custom Folder (SAF)';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String downloadFilenameDescription(
@@ -2939,62 +3139,73 @@ class AppLocalizationsHi extends AppLocalizations {
Object track,
Object year,
) {
return 'Customize how your files are named.';
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
}
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
String get downloadSeparateSinglesEnabled =>
'Singles and EPs saved in a separate folder';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
String get downloadSeparateSinglesDisabled =>
'Singles and albums saved in the same folder';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
'A subfolder is created for each playlist';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
'All tracks saved directly to download folder';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
'Handled by folder organization setting';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
'Using legacy TLS settings for older networks';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@override
String get downloadNeteaseIncludeTranslation =>
@@ -3002,11 +3213,11 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
'Chinese translation lines included';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
'Original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
@@ -3014,21 +3225,21 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
'Romanization lines included';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
'Speaker labels included for duets and group tracks';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@@ -3045,46 +3256,45 @@ class AppLocalizationsHi extends AppLocalizations {
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
String get downloadFilterContributing => 'Filter Contributing Artists';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
'Contributing artists removed from Album Artist folder name';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
'Full Album Artist string used';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
String get downloadProvidersNoneEnabled => 'No providers enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
'Downloads pause when on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@@ -3470,7 +3680,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks downloaded successfully',
one: '1 track downloaded successfully',
);
return '$_temp0';
}
@override
@@ -4240,4 +4456,318 @@ class AppLocalizationsHi extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
File diff suppressed because it is too large Load Diff
+606 -76
View File
@@ -126,7 +126,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get optionsPrimaryProviderSubtitle =>
'Service used when searching by track name.';
'Service used for searching by track or album name';
@override
String optionsUsingExtension(String extensionName) {
@@ -142,7 +142,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => 'Auto Fallback';
@@ -155,16 +155,19 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する';
@override
String get optionsUseExtensionProvidersOn => '最初に拡張で試みます';
String get optionsUseExtensionProvidersOn =>
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff => '内蔵のプロバイダーのみを使用する';
String get optionsUseExtensionProvidersOff =>
'Extension providers are required';
@override
String get optionsEmbedLyrics => '歌詞を埋め込む';
@override
String get optionsEmbedLyricsSubtitle => '同期する歌詞を FLAC ファイルに埋め込む';
String get optionsEmbedLyricsSubtitle =>
'Save synced lyrics alongside your downloaded tracks';
@override
String get optionsMaxQualityCover => '最大品質のカバー';
@@ -183,6 +186,43 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@@ -204,21 +244,6 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override
String get optionsConcurrentDownloads => '同時ダウンロード';
@override
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
@override
String optionsConcurrentParallel(int count) {
return '$count 件の分割ダウンロード';
}
@override
String get optionsConcurrentWarning =>
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Repo';
@@ -381,11 +406,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!';
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -575,6 +600,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => '破棄';
@@ -947,7 +981,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => '内蔵';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => '拡張';
@@ -1115,10 +1149,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get settingsAppearanceSubtitle => 'テーマ、カラー、画面';
@override
String get settingsDownloadSubtitle => 'サービス、品質、ファイル名、形式';
String get settingsDownloadSubtitle => 'Service, quality, fallback';
@override
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
@override
String get settingsExtensionsSubtitle => 'ダウンロードプロバイダーを管理';
@@ -1340,10 +1374,11 @@ class AppLocalizationsJa extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => '内蔵の検索を使用する';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => '作者';
@@ -1513,7 +1548,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@@ -1556,6 +1591,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
@override
String get albumFolderStructureDescription => 'アルバムフォルダの構成を選択';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2080,7 +2118,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2410,7 +2448,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'オーディオを変換';
@@ -2763,14 +2801,14 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
'Folder named after Album Artist tag';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
'Folder named after Track Artist tag';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
@override
String get lyricsProvidersDescription =>
@@ -2778,7 +2816,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2820,6 +2858,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@@ -2838,10 +2880,168 @@ class AppLocalizationsJa extends AppLocalizations {
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
String get settingsDonate => 'Support Development';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -2901,20 +3101,20 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
'Choose where to save your downloaded tracks';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
String get storageModeAppFolder => 'App Folder (Recommended)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
String get storageModeAppFolderSubtitle =>
'Saves to Music/SpotiFLAC by default';
@override
String get storageModeSaf => 'SAF folder';
String get storageModeSaf => 'Custom Folder (SAF)';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String downloadFilenameDescription(
@@ -2926,62 +3126,73 @@ class AppLocalizationsJa extends AppLocalizations {
Object track,
Object year,
) {
return 'Customize how your files are named.';
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
}
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
String get downloadSeparateSinglesEnabled =>
'Singles and EPs saved in a separate folder';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
String get downloadSeparateSinglesDisabled =>
'Singles and albums saved in the same folder';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
'A subfolder is created for each playlist';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
'All tracks saved directly to download folder';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
'Handled by folder organization setting';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
'Using legacy TLS settings for older networks';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@override
String get downloadNeteaseIncludeTranslation =>
@@ -2989,11 +3200,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
'Chinese translation lines included';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
'Original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
@@ -3001,21 +3212,21 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
'Romanization lines included';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
'Speaker labels included for duets and group tracks';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@@ -3032,46 +3243,45 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
String get downloadFilterContributing => 'Filter Contributing Artists';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
'Contributing artists removed from Album Artist folder name';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
'Full Album Artist string used';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
String get downloadProvidersNoneEnabled => 'No providers enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
'Downloads pause when on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@@ -3457,7 +3667,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks downloaded successfully',
one: '1 track downloaded successfully',
);
return '$_temp0';
}
@override
@@ -4227,4 +4443,318 @@ class AppLocalizationsJa extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+614 -79
View File
@@ -27,7 +27,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get homeTitle => 'Home';
@override
String get homeSubtitle => 'Paste a supported URL or search by name';
String get homeSubtitle => '지원되는 URL을 붙여 넣거나, 이름을 검색';
@override
String get homeEmptyTitle => 'No search providers yet';
@@ -97,10 +97,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get appearanceThemeSystem => 'System';
@override
String get appearanceThemeLight => 'Light';
String get appearanceThemeLight => '밝은';
@override
String get appearanceThemeDark => 'Dark';
String get appearanceThemeDark => '다크';
@override
String get appearanceDynamicColor => 'Dynamic Color';
@@ -124,7 +124,8 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsPrimaryProvider => '기본 제공자';
@override
String get optionsPrimaryProviderSubtitle => '음반 이름으로 검색할 때 사용되는 서비스';
String get optionsPrimaryProviderSubtitle =>
'Service used for searching by track or album name';
@override
String optionsUsingExtension(String extensionName) {
@@ -139,7 +140,8 @@ class AppLocalizationsKo extends AppLocalizations {
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
String get optionsSwitchBack =>
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => '자동 재시도';
@@ -151,16 +153,19 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsUseExtensionProviders => '확장 기능 사용';
@override
String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다';
String get optionsUseExtensionProvidersOn =>
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff => '기본으로 제공되는 기능만 사용';
String get optionsUseExtensionProvidersOff =>
'Extension providers are required';
@override
String get optionsEmbedLyrics => '가사 삽입';
@override
String get optionsEmbedLyricsSubtitle => 'FLAC 파일에 동기화된 가사를 삽입합니다';
String get optionsEmbedLyricsSubtitle =>
'Save synced lyrics alongside your downloaded tracks';
@override
String get optionsMaxQualityCover => '고품질 커버 이미지';
@@ -179,6 +184,43 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@@ -200,20 +242,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override
String get optionsConcurrentDownloads => '동시 다운로드';
@override
String get optionsConcurrentSequential => '순차 다운로드 (한 번에 하나)';
@override
String optionsConcurrentParallel(int count) {
return '$count개 동시 다운로드';
}
@override
String get optionsConcurrentWarning => '동시에 다수의 음반을 다운로드하면 속도 제한이 발생할 수 있습니다';
@override
String get optionsExtensionStore => 'Extension Repo';
@@ -374,10 +402,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'QQDL HiFi API 개발자입니다. 이 API가 없었다면 Tidal 다운로드는 불가능했을 것입니다!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc => '최초의 하이파이 프로젝트 창시자. 타이달 연동의 기반을 마련한 사람!';
String get aboutSachinsenalDesc =>
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -564,6 +593,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => '취소';
@@ -829,7 +867,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get tooltipPlay => '재생';
@override
String get filenameFormat => '';
String get filenameFormat => 'Filename Format';
@override
String get filenameShowAdvancedTags => '고급 태그 표시';
@@ -935,7 +973,7 @@ class AppLocalizationsKo extends AppLocalizations {
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => 'Extension';
@@ -1101,10 +1139,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get settingsAppearanceSubtitle => 'Theme, colors, display';
@override
String get settingsDownloadSubtitle => 'Service, quality, filename format';
String get settingsDownloadSubtitle => 'Service, quality, fallback';
@override
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
@override
String get settingsExtensionsSubtitle => 'Manage download providers';
@@ -1326,10 +1364,11 @@ class AppLocalizationsKo extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => 'Author';
@@ -1503,7 +1542,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@@ -1547,6 +1586,10 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2073,7 +2116,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2403,7 +2446,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2756,14 +2799,14 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
'Folder named after Album Artist tag';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
'Folder named after Track Artist tag';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
@override
String get lyricsProvidersDescription =>
@@ -2771,7 +2814,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2813,6 +2856,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@@ -2831,10 +2878,168 @@ class AppLocalizationsKo extends AppLocalizations {
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
String get settingsDonate => 'Support Development';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -2894,20 +3099,20 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
'Choose where to save your downloaded tracks';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
String get storageModeAppFolder => 'App Folder (Recommended)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
String get storageModeAppFolderSubtitle =>
'Saves to Music/SpotiFLAC by default';
@override
String get storageModeSaf => 'SAF folder';
String get storageModeSaf => 'Custom Folder (SAF)';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String downloadFilenameDescription(
@@ -2919,62 +3124,73 @@ class AppLocalizationsKo extends AppLocalizations {
Object track,
Object year,
) {
return 'Customize how your files are named.';
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
}
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
String get downloadSeparateSinglesEnabled =>
'Singles and EPs saved in a separate folder';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
String get downloadSeparateSinglesDisabled =>
'Singles and albums saved in the same folder';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
'A subfolder is created for each playlist';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
'All tracks saved directly to download folder';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
'Handled by folder organization setting';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
'Using legacy TLS settings for older networks';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@override
String get downloadNeteaseIncludeTranslation =>
@@ -2982,11 +3198,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
'Chinese translation lines included';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
'Original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
@@ -2994,21 +3210,21 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
'Romanization lines included';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
'Speaker labels included for duets and group tracks';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@@ -3025,46 +3241,45 @@ class AppLocalizationsKo extends AppLocalizations {
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
String get downloadFilterContributing => 'Filter Contributing Artists';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
'Contributing artists removed from Album Artist folder name';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
'Full Album Artist string used';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
String get downloadProvidersNoneEnabled => 'No providers enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
'Downloads pause when on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@@ -3450,7 +3665,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks downloaded successfully',
one: '1 track downloaded successfully',
);
return '$_temp0';
}
@override
@@ -4220,4 +4441,318 @@ class AppLocalizationsKo extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+607 -77
View File
@@ -126,7 +126,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get optionsPrimaryProviderSubtitle =>
'Service used when searching by track name.';
'Service used for searching by track or album name';
@override
String optionsUsingExtension(String extensionName) {
@@ -142,7 +142,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => 'Auto Fallback';
@@ -155,17 +155,19 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsUseExtensionProviders => 'Use Extension Providers';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
String get optionsUseExtensionProvidersOn =>
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
String get optionsUseExtensionProvidersOff =>
'Extension providers are required';
@override
String get optionsEmbedLyrics => 'Embed Lyrics';
@override
String get optionsEmbedLyricsSubtitle =>
'Embed synced lyrics into FLAC files';
'Save synced lyrics alongside your downloaded tracks';
@override
String get optionsMaxQualityCover => 'Max Quality Cover';
@@ -185,6 +187,43 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@@ -206,21 +245,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@override
String get optionsConcurrentSequential => 'Sequentiële (1 per keer)';
@override
String optionsConcurrentParallel(int count) {
return '';
}
@override
String get optionsConcurrentWarning =>
'Parallel downloaden kan leiden tot rate-limiting';
@override
String get optionsExtensionStore => 'Extension Repo';
@@ -323,7 +347,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutContributors => 'Contributors';
@override
String get aboutMobileDeveloper => '';
String get aboutMobileDeveloper => 'Mobile version developer';
@override
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
@@ -385,11 +409,11 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!';
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -579,6 +603,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -953,7 +986,7 @@ class AppLocalizationsNl extends AppLocalizations {
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => 'Extension';
@@ -1121,10 +1154,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get settingsAppearanceSubtitle => 'Theme, colors, display';
@override
String get settingsDownloadSubtitle => 'Service, quality, filename format';
String get settingsDownloadSubtitle => 'Service, quality, fallback';
@override
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
@override
String get settingsExtensionsSubtitle => 'Manage download providers';
@@ -1346,10 +1379,11 @@ class AppLocalizationsNl extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => 'Author';
@@ -1523,7 +1557,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@@ -1567,6 +1601,10 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2093,7 +2131,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2423,7 +2461,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2776,14 +2814,14 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
'Folder named after Album Artist tag';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
'Folder named after Track Artist tag';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
@override
String get lyricsProvidersDescription =>
@@ -2791,7 +2829,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2833,6 +2871,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@@ -2851,10 +2893,168 @@ class AppLocalizationsNl extends AppLocalizations {
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
String get settingsDonate => 'Support Development';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -2914,20 +3114,20 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
'Choose where to save your downloaded tracks';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
String get storageModeAppFolder => 'App Folder (Recommended)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
String get storageModeAppFolderSubtitle =>
'Saves to Music/SpotiFLAC by default';
@override
String get storageModeSaf => 'SAF folder';
String get storageModeSaf => 'Custom Folder (SAF)';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String downloadFilenameDescription(
@@ -2939,62 +3139,73 @@ class AppLocalizationsNl extends AppLocalizations {
Object track,
Object year,
) {
return 'Customize how your files are named.';
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
}
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
String get downloadSeparateSinglesEnabled =>
'Singles and EPs saved in a separate folder';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
String get downloadSeparateSinglesDisabled =>
'Singles and albums saved in the same folder';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
'A subfolder is created for each playlist';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
'All tracks saved directly to download folder';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
'Handled by folder organization setting';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
'Using legacy TLS settings for older networks';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@override
String get downloadNeteaseIncludeTranslation =>
@@ -3002,11 +3213,11 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
'Chinese translation lines included';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
'Original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
@@ -3014,21 +3225,21 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
'Romanization lines included';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
'Speaker labels included for duets and group tracks';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@@ -3045,46 +3256,45 @@ class AppLocalizationsNl extends AppLocalizations {
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
String get downloadFilterContributing => 'Filter Contributing Artists';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
'Contributing artists removed from Album Artist folder name';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
'Full Album Artist string used';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
String get downloadProvidersNoneEnabled => 'No providers enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
'Downloads pause when on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@@ -3470,7 +3680,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks downloaded successfully',
one: '1 track downloaded successfully',
);
return '$_temp0';
}
@override
@@ -4240,4 +4456,318 @@ class AppLocalizationsNl extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
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
+608 -84
View File
@@ -129,7 +129,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get optionsPrimaryProviderSubtitle =>
'Розширення будуть випробувані першими';
'Service used for searching by track or album name';
@override
String optionsUsingExtension(String extensionName) {
@@ -137,15 +137,15 @@ class AppLocalizationsUk extends AppLocalizations {
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
String get optionsDefaultSearchTab => 'Вкладка пошуку за замовчуванням';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
'Виберіть, яка вкладка відкриється першою для нових результатів пошуку.';
@override
String get optionsSwitchBack =>
'Натисніть Deezer або Spotify, щоб повернутися до розширення';
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => 'Автоматичний резервний варіант';
@@ -160,18 +160,18 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get optionsUseExtensionProvidersOn =>
'Розширення будуть випробувані першими';
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff =>
'Використати лише вбудованих постачальників';
'Extension providers are required';
@override
String get optionsEmbedLyrics => 'Вбудований текст пісні';
@override
String get optionsEmbedLyricsSubtitle =>
'Вбудовувати синхронізовані тексти пісень у файли FLAC';
'Save synced lyrics alongside your downloaded tracks';
@override
String get optionsMaxQualityCover => 'Максимальна якість обкладинки';
@@ -191,6 +191,43 @@ class AppLocalizationsUk extends AppLocalizations {
String get optionsReplayGainSubtitleOff =>
'Вимкнено: немає тегів нормалізації гучності';
@override
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Режим тегу виконавця';
@@ -212,21 +249,6 @@ class AppLocalizationsUk extends AppLocalizations {
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Для FLAC та Opus на кожного виконавця додати окремий тег виконавця; MP3 та M4A залишаються об’єднаними.';
@override
String get optionsConcurrentDownloads => 'Кількість одночасних завантажень';
@override
String get optionsConcurrentSequential => 'Послідовно (по одному за раз)';
@override
String optionsConcurrentParallel(int count) {
return '$count паралельних завантажень';
}
@override
String get optionsConcurrentWarning =>
'Паралельні завантаження можуть призвести до обмеження швидкості';
@override
String get optionsExtensionStore => 'Репозиторій розширень';
@@ -395,11 +417,11 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'Творець QQDL та HiFi API. Без цього API завантажень Tidal\'а не існувало б!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc =>
'Оригінальний творець HiFi-проектів. Основа інтеграції Tidal!';
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -590,6 +612,15 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get dialogDownload => 'Завантажити';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Відхилити';
@@ -959,14 +990,14 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get providerPriorityFallbackExtensionsDescription =>
'Виберіть, які встановлені розширення завантаження можна використовувати під час автоматичного відновлення до попереднього режиму. Вбудовані постачальники все одно дотримуються порядку пріоритетності, зазначеного вище.';
'Choose which installed download extensions can be used during automatic fallback.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Тут перелічені лише ввімкнені розширення з можливістю завантаження через постачальника послуг.';
@override
String get providerBuiltIn => 'Вбудований';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => 'Розширення';
@@ -1137,11 +1168,10 @@ class AppLocalizationsUk extends AppLocalizations {
String get settingsAppearanceSubtitle => 'Тема, кольори, дисплей';
@override
String get settingsDownloadSubtitle => 'Сервіс, якість, формат назви файлу';
String get settingsDownloadSubtitle => 'Service, quality, fallback';
@override
String get settingsOptionsSubtitle =>
'Резервний варіант, тексти пісень, обкладинка, оновлення';
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
@override
String get settingsExtensionsSubtitle =>
@@ -1368,10 +1398,11 @@ class AppLocalizationsUk extends AppLocalizations {
String get storeEmptyNoResults => 'Розширень не знайдено';
@override
String get extensionDefaultProvider => 'За замовчуванням (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => 'Використати вбудований пошук';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => 'Автор';
@@ -1547,7 +1578,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Виберіть вихідний формат для завантажень Tidal 320 кбіт/с із втратами. Оригінальний потік AAC буде конвертовано у вибраний вами формат.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320 кбіт/с';
@@ -1593,6 +1624,10 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Структура папок альбому';
@override
String get albumFolderStructureDescription =>
'Виберіть структуру папок альбомів';
@override
String get downloadUseAlbumArtistForFolders =>
'Використовувати виконавця альбому для папок';
@@ -2129,7 +2164,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Отримуйте аудіо у якості FLAC з Tidal, Qobuz або Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2464,7 +2499,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Конвертувати в MP3, Opus, ALAC або FLAC';
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Конвертувати аудіо';
@@ -2821,14 +2856,14 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Папки виконавців використовують \"Виконавець альбому\", коли це можливо';
'Folder named after Album Artist tag';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Папки виконавців використовують лише виконавця доріжки';
'Folder named after Track Artist tag';
@override
String get lyricsProvidersTitle => 'Постачальники текстів пісень';
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
@override
String get lyricsProvidersDescription =>
@@ -2836,7 +2871,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Постачальники розширених текстів пісень завжди запускаються перед вбудованими постачальниками. Принаймні один постачальник має залишатися ввімкненим.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2880,6 +2915,10 @@ class AppLocalizationsUk extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (добре для китайських пісень, через проксі)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Постачальник розширень';
@@ -2898,11 +2937,168 @@ class AppLocalizationsUk extends AppLocalizations {
String get safMigrationSuccess => 'Папку завантажень оновлено до режиму SAF';
@override
String get settingsDonate => 'Пожертвувати кошти';
String get settingsDonate => 'Support Development';
@override
String get settingsDonateSubtitle =>
'Підтримка розробки SpotiFLAC для мобільних пристроїв';
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Уподобати всіх';
@@ -2965,21 +3161,20 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadLocationSubtitle =>
'Виберіть режим зберігання для завантажених файлів.';
'Choose where to save your downloaded tracks';
@override
String get storageModeAppFolder => 'Папка додатку (не SAF)';
String get storageModeAppFolder => 'App Folder (Recommended)';
@override
String get storageModeAppFolderSubtitle =>
'Використовувати шлях Music/SpotiFLAC за замовчуванням';
'Saves to Music/SpotiFLAC by default';
@override
String get storageModeSaf => 'Папка SAF';
String get storageModeSaf => 'Custom Folder (SAF)';
@override
String get storageModeSafSubtitle =>
'Вибрати папку через Android Storage Access Framework';
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String downloadFilenameDescription(
@@ -2991,73 +3186,84 @@ class AppLocalizationsUk extends AppLocalizations {
Object track,
Object year,
) {
return 'Налаштувати спосіб іменування ваших файлів.';
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
}
@override
String get downloadFilenameInsertTag => 'Натисніть, щоб вставити тег:';
@override
String get downloadSeparateSinglesEnabled => 'Папки «Альбоми» та «Сингли»';
String get downloadSeparateSinglesEnabled =>
'Singles and EPs saved in a separate folder';
@override
String get downloadSeparateSinglesDisabled => 'Всі файли в одній структурі';
String get downloadSeparateSinglesDisabled =>
'Singles and albums saved in the same folder';
@override
String get downloadArtistNameFilters => 'Фільтри імені виконавця';
@override
String get downloadCreatePlaylistSourceFolder =>
'Створити папку джерела списку відтворення';
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Завантаження списків відтворення використовує Playlist/ плюс вашу звичайну структуру папок.';
'A subfolder is created for each playlist';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Завантаження списків відтворення використовують лише звичайну структуру папок.';
'All tracks saved directly to download folder';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'За допомогою списку відтворення завантаження вже розміщуються в папці зі списком відтворення.';
'Handled by folder organization setting';
@override
String get downloadSongLinkRegion => 'Регіон SongLink';
@override
String get downloadNetworkCompatibilityMode => 'Режим сумісності з мережею';
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Увімкнено: спробувати HTTP + прийняти недійсні сертифікати TLS (небезпечно)';
'Using legacy TLS settings for older networks';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Вимкнено: сувора перевірка сертифіката HTTPS (рекомендовано)';
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Виберіть вбудовану службу, яку потрібно ввімкнути';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Виберіть Tidal або Qobuz вище, щоб налаштувати якість';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled =>
'Вимкнено, якщо вимкнено функцію «Вбудувати метадані»';
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@override
String get downloadNeteaseIncludeTranslation => 'Netease: Включити переклад';
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Додати перекладені тексти пісень, коли вони доступні';
'Chinese translation lines included';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Використовувати лише оригінальні тексти пісень';
'Original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
@@ -3065,22 +3271,21 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Додати романізовані тексти пісень, коли це можливо';
'Romanization lines included';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Вимкнути';
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
@override
String get downloadAppleQqMultiPerson =>
'Apple/QQ Багатокористувацький переклад слово за словом';
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Увімкнути теги динаміка v1/v2 та [bg:]';
'Speaker labels included for duets and group tracks';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Спрощене послівне форматування';
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@@ -3097,46 +3302,45 @@ class AppLocalizationsUk extends AppLocalizations {
String get downloadMusixmatchLanguage => 'Мова Musixmatch';
@override
String get downloadMusixmatchLanguageAuto => 'Авто (оригінал)';
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
@override
String get downloadFilterContributing =>
'Фільтрувати виконавців-учасників у розділі «Виконавець альбому»';
String get downloadFilterContributing => 'Filter Contributing Artists';
@override
String get downloadFilterContributingEnabled =>
'Метадані виконавця альбому використовують лише основного виконавця';
'Contributing artists removed from Album Artist folder name';
@override
String get downloadFilterContributingDisabled =>
'Зберегти повне значення метаданих виконавця альбому';
'Full Album Artist string used';
@override
String get downloadProvidersNoneEnabled => 'Не ввімкнено';
String get downloadProvidersNoneEnabled => 'No providers enabled';
@override
String get downloadMusixmatchLanguageCode => 'Код мови';
@override
String get downloadMusixmatchLanguageHint => 'авто / en / es / ja';
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Встановити потрібний код мови (наприклад: en, es, ja). Залиште поле порожнім для автоматичного вибору.';
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
@override
String get downloadMusixmatchAuto => 'Авто';
@override
String get downloadNetworkAnySubtitle => 'Wi-Fi + мобільний інтернет';
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Призупинити завантаження через мобільний інтернет';
'Downloads pause when on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Використовувати як userCountry для пошуку SongLink API.';
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
@override
String get snackbarUnsupportedAudioFormat => 'Непідтримуваний аудіоформат';
@@ -3529,7 +3733,13 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String notifTracksDownloadedSuccess(int count) {
return '$count треки успішно завантажено';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks downloaded successfully',
one: '1 track downloaded successfully',
);
return '$_temp0';
}
@override
@@ -3609,7 +3819,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Завантаження SpotiFLAC Mobile v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3622,7 +3832,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC Mobile v$version завантажений. Натисніть щоб установити.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
@@ -4299,4 +4509,318 @@ class AppLocalizationsUk extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1191 -235
View File
File diff suppressed because it is too large Load Diff
+703 -53
View File
@@ -174,9 +174,9 @@
"@optionsDefaultSearchTabSubtitle": {
"description": "Subtitle for the preferred default search tab setting"
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
"description": "Hint to switch back from extension search"
},
"optionsAutoFallback": "Auto Fallback",
"@optionsAutoFallback": {
@@ -188,15 +188,15 @@
},
"optionsUseExtensionProviders": "Use Extension Providers",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
"description": "Legacy setting label for extension download providers"
},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "Extension providers are required",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
"description": "Legacy status when extension providers would be disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"@optionsEmbedLyrics": {
@@ -226,6 +226,64 @@
"@optionsReplayGainSubtitleOff": {
"description": "Subtitle when ReplayGain is disabled"
},
"trackReplayGain": "Rescan ReplayGain",
"@trackReplayGain": {
"description": "Three-dot menu option to scan loudness and write ReplayGain tags"
},
"trackReplayGainSubtitle": "Analyze loudness and write ReplayGain tags",
"@trackReplayGainSubtitle": {
"description": "Subtitle for the rescan ReplayGain menu option"
},
"trackReplayGainScanning": "Analyzing loudness...",
"@trackReplayGainScanning": {
"description": "Snackbar/progress message while scanning ReplayGain for a single track"
},
"trackReplayGainSuccess": "ReplayGain tags added",
"@trackReplayGainSuccess": {
"description": "Snackbar message after ReplayGain tags written for a single track"
},
"trackReplayGainFailed": "Failed to add ReplayGain tags",
"@trackReplayGainFailed": {
"description": "Snackbar message when ReplayGain scan/write fails"
},
"selectionReplayGainCount": "ReplayGain ({count})",
"@selectionReplayGainCount": {
"description": "Batch selection action button label for ReplayGain",
"placeholders": {
"count": {
"type": "int"
}
}
},
"replayGainBatchConfirmTitle": "Add ReplayGain",
"@replayGainBatchConfirmTitle": {
"description": "Title of the batch ReplayGain confirmation dialog"
},
"replayGainBatchConfirmMessage": "Analyze loudness and write ReplayGain tags to {count} track(s)?",
"@replayGainBatchConfirmMessage": {
"description": "Message of the batch ReplayGain confirmation dialog",
"placeholders": {
"count": {
"type": "int"
}
}
},
"replayGainBatchAnalyzing": "Analyzing ReplayGain...",
"@replayGainBatchAnalyzing": {
"description": "Progress dialog title while batch scanning ReplayGain"
},
"replayGainBatchSuccess": "ReplayGain added to {success} of {total} tracks",
"@replayGainBatchSuccess": {
"description": "Snackbar after batch ReplayGain completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"optionsArtistTagMode": "Artist Tag Mode",
"@optionsArtistTagMode": {
"description": "Setting title for how artist metadata is written into files"
@@ -250,27 +308,6 @@
"@optionsArtistTagModeSplitVorbisSubtitle": {
"description": "Subtitle for split Vorbis artist tag mode"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
"count": {
"type": "int"
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Repo",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
@@ -486,11 +523,11 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
@@ -735,6 +772,18 @@
"@dialogDownload": {
"description": "Confirm button in Download All dialog"
},
"previewPlay": "Play preview",
"@previewPlay": {
"description": "Tooltip for the button that plays a short track preview snippet"
},
"previewStop": "Stop preview",
"@previewStop": {
"description": "Tooltip for the button that stops the playing track preview snippet"
},
"previewUnavailable": "Preview unavailable",
"@previewUnavailable": {
"description": "Snackbar shown when a track preview snippet cannot be played"
},
"dialogDiscard": "Discard",
"@dialogDiscard": {
"description": "Dialog button - discard changes"
@@ -1235,9 +1284,9 @@
"@providerPriorityFallbackExtensionsHint": {
"description": "Hint below the extension fallback selection list"
},
"providerBuiltIn": "Built-in",
"providerBuiltIn": "Legacy",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
"description": "Legacy label retained for old generated localization compatibility"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1759,11 +1808,11 @@
"@storeEmptyNoResults": {
"description": "Message when search/filter returns no results"
},
"extensionDefaultProvider": "Default (Deezer)",
"extensionDefaultProvider": "Default Search",
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
"extensionDefaultProviderSubtitle": "Use built-in search",
"extensionDefaultProviderSubtitle": "Use the default metadata search",
"@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider"
},
@@ -1992,51 +2041,51 @@
},
"downloadLossy320": "Lossy 320kbps",
"@downloadLossy320": {
"description": "Quality option label for Tidal lossy 320kbps"
"description": "Quality option label for lossy 320kbps"
},
"downloadLossyFormat": "Lossy Format",
"@downloadLossyFormat": {
"description": "Setting title to pick output format for Tidal lossy downloads"
"description": "Setting title to pick output format for lossy downloads"
},
"downloadLossy320Format": "Lossy 320kbps Format",
"@downloadLossy320Format": {
"description": "Title of the Tidal lossy format picker bottom sheet"
"description": "Title of the lossy format picker bottom sheet"
},
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
"downloadLossy320FormatDesc": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
"@downloadLossy320FormatDesc": {
"description": "Description in the Tidal lossy format picker"
"description": "Description in the lossy format picker"
},
"downloadLossyMp3": "MP3 320kbps",
"@downloadLossyMp3": {
"description": "Tidal lossy format option - MP3 320kbps"
"description": "Lossy format option - MP3 320kbps"
},
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps Tidal lossy option"
"description": "Subtitle for MP3 320kbps lossy option"
},
"downloadLossyAac": "AAC/M4A 320kbps",
"@downloadLossyAac": {
"description": "Tidal lossy format option - AAC in M4A container at 320kbps"
"description": "Lossy format option - AAC in M4A container at 320kbps"
},
"downloadLossyAacSubtitle": "Best mobile compatibility, M4A container",
"@downloadLossyAacSubtitle": {
"description": "Subtitle for AAC/M4A 320kbps Tidal lossy option"
"description": "Subtitle for AAC/M4A 320kbps lossy option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
"description": "Lossy format option - Opus 256kbps"
},
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
"@downloadLossyOpus256Subtitle": {
"description": "Subtitle for Opus 256kbps Tidal lossy option"
"description": "Subtitle for Opus 256kbps lossy option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
"description": "Lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps Tidal lossy option"
"description": "Subtitle for Opus 128kbps lossy option"
},
"qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {
@@ -2058,6 +2107,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
@@ -2519,7 +2572,7 @@
"@libraryAbout": {
"description": "Section header for about info"
},
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.",
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.",
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
@@ -2745,7 +2798,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -3704,7 +3757,7 @@
"@lyricsProvidersDescription": {
"description": "Description on the lyrics provider priority page"
},
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
"@lyricsProvidersInfoText": {
"description": "Info tip on lyrics provider priority page"
},
@@ -3758,6 +3811,10 @@
"@lyricsProviderQqMusicDesc": {
"description": "Description for QQ Music provider"
},
"lyricsProviderLyricsPlusDesc": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)",
"@lyricsProviderLyricsPlusDesc": {
"description": "Description for LyricsPlus provider"
},
"lyricsProviderExtensionDesc": "Extension provider",
"@lyricsProviderExtensionDesc": {
"description": "Generic description for extension-based lyrics providers"
@@ -3786,6 +3843,169 @@
"@settingsDonateSubtitle": {
"description": "Subtitle for donate menu item"
},
"settingsBackup": "Backup & Restore",
"@settingsBackup": {
"description": "Settings menu item - backup and restore page"
},
"settingsBackupSubtitle": "Move your library, history and settings to a new device",
"@settingsBackupSubtitle": {
"description": "Subtitle for backup and restore settings item"
},
"backupTitle": "Backup & Restore",
"@backupTitle": {
"description": "App bar title for the backup and restore page"
},
"backupExportSectionTitle": "Create backup",
"@backupExportSectionTitle": {
"description": "Section title for the export/backup card"
},
"backupExportSectionDescription": "Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.",
"@backupExportSectionDescription": {
"description": "Description of what a backup contains"
},
"backupExportButton": "Create backup file",
"@backupExportButton": {
"description": "Button to create and share a backup file"
},
"backupImportSectionTitle": "Restore backup",
"@backupImportSectionTitle": {
"description": "Section title for the import/restore card"
},
"backupImportSectionDescription": "Pick a backup file to restore your data. This replaces the current settings, history and library on this device.",
"@backupImportSectionDescription": {
"description": "Description for the restore action"
},
"backupImportButton": "Choose backup file",
"@backupImportButton": {
"description": "Button to pick a backup file to restore"
},
"backupCreating": "Creating backup...",
"@backupCreating": {
"description": "Progress text while a backup is being created"
},
"backupCreated": "Backup created",
"@backupCreated": {
"description": "Snackbar after a backup file is created"
},
"backupCreateFailed": "Failed to create backup",
"@backupCreateFailed": {
"description": "Snackbar when backup creation fails"
},
"backupEmpty": "There is nothing to back up yet",
"@backupEmpty": {
"description": "Snackbar when there is no data to back up"
},
"backupRestoreConfirmTitle": "Restore this backup?",
"@backupRestoreConfirmTitle": {
"description": "Confirmation dialog title before restoring a backup"
},
"backupRestoreConfirmMessage": "This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.",
"@backupRestoreConfirmMessage": {
"description": "Confirmation dialog message before restoring a backup"
},
"backupRestoreConfirmButton": "Restore",
"@backupRestoreConfirmButton": {
"description": "Confirm button to proceed with restore"
},
"backupRestoring": "Restoring backup...",
"@backupRestoring": {
"description": "Progress text while restoring a backup"
},
"backupRestored": "Backup restored successfully",
"@backupRestored": {
"description": "Snackbar after a successful restore"
},
"backupRestoreFailed": "Failed to restore backup",
"@backupRestoreFailed": {
"description": "Snackbar when restore fails"
},
"backupInvalidFile": "This file is not a valid SpotiFLAC backup",
"@backupInvalidFile": {
"description": "Snackbar when the chosen file is not a valid backup"
},
"backupRestoreRestartHint": "Restart the app to make sure every change is applied.",
"@backupRestoreRestartHint": {
"description": "Hint shown after restoring that an app restart is recommended"
},
"backupContentsTitle": "Backup contents",
"@backupContentsTitle": {
"description": "Header above the list summarizing what the backup contains"
},
"backupContentsSettings": "App settings",
"@backupContentsSettings": {
"description": "Backup contents row label for settings"
},
"backupContentsHistory": "{count} history {count, plural, =1{item} other{items}}",
"@backupContentsHistory": {
"description": "Backup contents row for history count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsLiked": "{count} liked {count, plural, =1{track} other{tracks}}",
"@backupContentsLiked": {
"description": "Backup contents row for liked tracks count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsWishlist": "{count} wishlist {count, plural, =1{track} other{tracks}}",
"@backupContentsWishlist": {
"description": "Backup contents row for wishlist tracks count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsPlaylists": "{count, plural, =1{1 playlist} other{{count} playlists}}",
"@backupContentsPlaylists": {
"description": "Backup contents row for playlist count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsArtists": "{count, plural, =1{1 favorite artist} other{{count} favorite artists}}",
"@backupContentsArtists": {
"description": "Backup contents row for favorite artists count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsExtensions": "{count, plural, =1{1 extension} other{{count} extensions}}",
"@backupContentsExtensions": {
"description": "Backup contents row for installed extensions count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupIncludeSecrets": "Include extension credentials",
"@backupIncludeSecrets": {
"description": "Toggle to include secret extension settings (tokens, API keys) in the backup"
},
"backupIncludeSecretsDescription": "Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.",
"@backupIncludeSecretsDescription": {
"description": "Explanation for the include-credentials toggle"
},
"backupExtensionsRestoreFailed": "{count} {count, plural, =1{extension} other{extensions}} could not be reinstalled. Install them manually from the store.",
"@backupExtensionsRestoreFailed": {
"description": "Snackbar/hint when some extensions failed to reinstall during restore",
"placeholders": {
"count": {
"type": "int"
}
}
},
"tooltipLoveAll": "Love All",
"@tooltipLoveAll": {
"description": "Tooltip for the Love All button on album/playlist screens"
@@ -3942,13 +4162,25 @@
"@downloadNetworkCompatibilityModeDisabled": {
"description": "Subtitle when network compatibility mode is off"
},
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
"downloadAllowLocalNetwork": "Allow Local Network Access",
"@downloadAllowLocalNetwork": {
"description": "Setting title for allowing requests to private/local network targets"
},
"downloadAllowLocalNetworkEnabled": "Requests to local/private addresses are allowed (for local proxy or custom DNS)",
"@downloadAllowLocalNetworkEnabled": {
"description": "Subtitle when allow local network access is on"
},
"downloadAllowLocalNetworkDisabled": "Local/private addresses are blocked for security",
"@downloadAllowLocalNetworkDisabled": {
"description": "Subtitle when allow local network access is off"
},
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
"@downloadSelectServiceToEnable": {
"description": "Subtitle when quality picker is disabled due to extension service"
},
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
"@downloadSelectTidalQobuz": {
"description": "Info shown when a non-built-in service is selected"
"description": "Legacy info shown when a provider does not expose quality options"
},
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
"@downloadEmbedLyricsDisabled": {
@@ -4358,7 +4590,7 @@
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
"description": "Extensions page - subtitle for default metadata search provider option",
"placeholders": {
"providerName": {
"type": "String"
@@ -5554,5 +5786,423 @@
"placeholders": {
"service": {}
}
},
"libraryPlayback": "Playback",
"@libraryPlayback": {
"description": "Section header for playback settings in library settings"
},
"libraryExternalPlayer": "External player",
"@libraryExternalPlayer": {
"description": "Setting option to use an external music player"
},
"libraryExternalPlayerSubtitle": "Recommended for listening, best quality, gapless playback, EQ, and wider format support",
"@libraryExternalPlayerSubtitle": {
"description": "Subtitle for external player option"
},
"libraryBuiltInPreviewPlayer": "Built-in preview player",
"@libraryBuiltInPreviewPlayer": {
"description": "Setting option to use the built-in preview player"
},
"libraryBuiltInPreviewPlayerSubtitle": "Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening",
"@libraryBuiltInPreviewPlayerSubtitle": {
"description": "Subtitle for built-in preview player option"
},
"libraryBuiltInPlayerInfo": "The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.",
"@libraryBuiltInPlayerInfo": {
"description": "Info note explaining the built-in player is for previews only"
},
"nowPlayingTitle": "Now Playing",
"@nowPlayingTitle": {
"description": "Title for the now playing screen"
},
"nowPlayingNothingPlaying": "Nothing is playing",
"@nowPlayingNothingPlaying": {
"description": "Empty state when no track is currently playing"
},
"nowPlayingMinimize": "Minimize",
"@nowPlayingMinimize": {
"description": "Tooltip for minimizing the now playing screen"
},
"nowPlayingUpNext": "Up next",
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingDetails": "Details",
"@nowPlayingDetails": {
"description": "Menu item and section title for track metadata details"
},
"nowPlayingOpenInExternalPlayer": "Open in external player",
"@nowPlayingOpenInExternalPlayer": {
"description": "Menu item to open the current track in an external player"
},
"nowPlayingTabPlayer": "Player",
"@nowPlayingTabPlayer": {
"description": "Tab label for the player view"
},
"nowPlayingTabLyrics": "Lyrics",
"@nowPlayingTabLyrics": {
"description": "Tab label for the lyrics view"
},
"nowPlayingNoLyrics": "No lyrics in this file",
"@nowPlayingNoLyrics": {
"description": "Empty state when the playing file has no embedded lyrics"
},
"nowPlayingLibraryEmpty": "Your library is empty",
"@nowPlayingLibraryEmpty": {
"description": "Snackbar when shuffle library is requested but library has no tracks"
},
"nowPlayingShuffleLibraryFailed": "Could not shuffle library: {error}",
"@nowPlayingShuffleLibraryFailed": {
"description": "Snackbar when shuffling the library fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"nowPlayingShuffleOn": "Shuffle on",
"@nowPlayingShuffleOn": {
"description": "Tooltip when shuffle mode is enabled"
},
"nowPlayingPlayInOrder": "Play in order",
"@nowPlayingPlayInOrder": {
"description": "Tooltip when shuffle mode is disabled"
},
"nowPlayingShuffleLibrary": "Shuffle library",
"@nowPlayingShuffleLibrary": {
"description": "Button label to shuffle and play the entire local library"
},
"nowPlayingQueueEmpty": "Queue is empty",
"@nowPlayingQueueEmpty": {
"description": "Empty state when the playback queue has no items"
},
"nowPlayingNoMetadata": "No metadata available",
"@nowPlayingNoMetadata": {
"description": "Empty state when track metadata cannot be loaded"
},
"announcementUnableToOpenLink": "Unable to open link. Please try again.",
"@announcementUnableToOpenLink": {
"description": "Snackbar shown when an announcement CTA link cannot be opened"
},
"trackConvertLosslessOutputWithCap": "Lossless output with {quality} cap",
"@trackConvertLosslessOutputWithCap": {
"description": "Hint shown when lossless conversion will cap bit depth or sample rate",
"placeholders": {
"quality": {
"type": "String"
}
}
},
"trackConvertConfirmMessageLosslessCapped": "Convert from {sourceFormat} to {targetFormat} ({quality})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.",
"@trackConvertConfirmMessageLosslessCapped": {
"description": "Confirmation dialog message for capped lossless conversion of a single file",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
},
"quality": {
"type": "String"
}
}
},
"selectionBatchConvertConfirmMessageLosslessCapped": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} ({quality})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessageLosslessCapped": {
"description": "Confirmation dialog message for capped lossless batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"quality": {
"type": "String"
}
}
},
"trackConvertActionLabelLossless": "{sourceFormat} → {targetFormat} ({quality})",
"@trackConvertActionLabelLossless": {
"description": "Convert button label for lossless conversion with quality cap",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
},
"quality": {
"type": "String"
}
}
},
"trackConvertActionLabelLossy": "{sourceFormat} → {targetFormat} @ {bitrate}",
"@trackConvertActionLabelLossy": {
"description": "Convert button label for lossy conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"aboutPaxsenixSubtitle": "Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius",
"@aboutPaxsenixSubtitle": {
"description": "Subtitle for Paxsenix special thanks entry on the about page"
},
"snackbarPlayingNext": "Playing next",
"@snackbarPlayingNext": {
"description": "Snackbar when a track is inserted as the next queue item"
},
"snackbarAddedToQueueGeneric": "Added to queue",
"@snackbarAddedToQueueGeneric": {
"description": "Snackbar when a track is added to the playback queue without naming it"
},
"selectionDeletePlaylistsCount": "Delete {count} {count, plural, =1{playlist} other{playlists}}",
"@selectionDeletePlaylistsCount": {
"description": "Button label for deleting multiple selected playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"actionShuffle": "Shuffle",
"@actionShuffle": {
"description": "Tooltip for shuffle playback action"
},
"downloadPrimaryArtistOnlyOn": "Primary only: On",
"@downloadPrimaryArtistOnlyOn": {
"description": "Status label when primary-artist-only folder naming is enabled"
},
"downloadPrimaryArtistOnlyOff": "Primary only: Off",
"@downloadPrimaryArtistOnlyOff": {
"description": "Status label when primary-artist-only folder naming is disabled"
},
"downloadAlbumArtistMetadataPrimaryOnly": "Album Artist metadata: Primary only",
"@downloadAlbumArtistMetadataPrimaryOnly": {
"description": "Status label when album-artist folder filtering uses primary artist only"
},
"downloadAlbumArtistMetadataFull": "Album Artist metadata: Full",
"@downloadAlbumArtistMetadataFull": {
"description": "Status label when album-artist folder filtering uses full metadata"
},
"trackConvertOriginal": "Original",
"@trackConvertOriginal": {
"description": "Label for keeping original bit depth or sample rate during conversion"
},
"trackConvertOriginalQuality": "Original quality",
"@trackConvertOriginalQuality": {
"description": "Label when no bit depth or sample rate cap is applied during lossless conversion"
},
"trackConvertLosslessSuffix": "Lossless",
"@trackConvertLosslessSuffix": {
"description": "Suffix used in converted lossless quality labels"
},
"trackConvertDithering": "Dithering",
"@trackConvertDithering": {
"description": "Section label for lossless conversion dithering options"
},
"trackConvertResampler": "Resampler",
"@trackConvertResampler": {
"description": "Section label for lossless conversion resampler options"
},
"trackConvertDitherNone": "None",
"@trackConvertDitherNone": {
"description": "Lossless conversion dither option with no dithering applied"
},
"trackConvertDitherTriangular": "TPDF",
"@trackConvertDitherTriangular": {
"description": "Lossless conversion triangular probability density function dither option"
},
"trackConvertDitherTriangularHp": "Triangular HP",
"@trackConvertDitherTriangularHp": {
"description": "Lossless conversion high-pass triangular dither option"
},
"trackConvertResamplerSwr": "SWR",
"@trackConvertResamplerSwr": {
"description": "Lossless conversion default FFmpeg swresample resampler option"
},
"trackConvertResamplerSoxr": "SoXr",
"@trackConvertResamplerSoxr": {
"description": "Lossless conversion SoX resampler option"
},
"updateSeeReleaseNotes": "See release notes for details.",
"@updateSeeReleaseNotes": {
"description": "Fallback changelog text when release notes cannot be parsed"
},
"unknownTitle": "Unknown title",
"@unknownTitle": {
"description": "Fallback track title when metadata is missing"
},
"trackPlayNext": "Play next",
"@trackPlayNext": {
"description": "Menu action to play a track as the next queue item"
},
"trackAddToQueue": "Add to queue",
"@trackAddToQueue": {
"description": "Menu action to add a track to the playback queue"
},
"snackbarExtensionInstalledEnable": "{extensionName} installed. Enable it in Settings > Extensions",
"@snackbarExtensionInstalledEnable": {
"description": "Snackbar after installing an extension from the repo tab",
"placeholders": {
"extensionName": {
"type": "String"
}
}
},
"snackbarExtensionUpdatedVersion": "{extensionName} updated to v{version}",
"@snackbarExtensionUpdatedVersion": {
"description": "Snackbar after updating an extension from the repo tab",
"placeholders": {
"extensionName": {
"type": "String"
},
"version": {
"type": "String"
}
}
},
"snackbarFailedToInstallNamed": "Failed to install {extensionName}",
"@snackbarFailedToInstallNamed": {
"description": "Snackbar when extension install fails in the repo tab",
"placeholders": {
"extensionName": {
"type": "String"
}
}
},
"snackbarFailedToUpdateNamed": "Failed to update {extensionName}",
"@snackbarFailedToUpdateNamed": {
"description": "Snackbar when extension update fails in the repo tab",
"placeholders": {
"extensionName": {
"type": "String"
}
}
},
"releaseTypeEp": "EP",
"@releaseTypeEp": {
"description": "Badge label for EP releases"
},
"releaseTypeSingle": "Single",
"@releaseTypeSingle": {
"description": "Badge label for single releases"
},
"trackCoverOnline": "Online cover",
"@trackCoverOnline": {
"description": "Label shown when metadata autofill downloaded cover art from the internet"
},
"regionCountryUS": "United States",
"@regionCountryUS": {
"description": "Country name for SongLink region picker"
},
"regionCountryGB": "United Kingdom",
"@regionCountryGB": {
"description": "Country name for SongLink region picker"
},
"regionCountryFR": "France",
"@regionCountryFR": {
"description": "Country name for SongLink region picker"
},
"regionCountryDE": "Germany",
"@regionCountryDE": {
"description": "Country name for SongLink region picker"
},
"regionCountryJP": "Japan",
"@regionCountryJP": {
"description": "Country name for SongLink region picker"
},
"regionCountryKR": "South Korea",
"@regionCountryKR": {
"description": "Country name for SongLink region picker"
},
"regionCountryIN": "India",
"@regionCountryIN": {
"description": "Country name for SongLink region picker"
},
"regionCountryID": "Indonesia",
"@regionCountryID": {
"description": "Country name for SongLink region picker"
},
"regionCountryBR": "Brazil",
"@regionCountryBR": {
"description": "Country name for SongLink region picker"
},
"regionCountryMX": "Mexico",
"@regionCountryMX": {
"description": "Country name for SongLink region picker"
},
"regionCountryAU": "Australia",
"@regionCountryAU": {
"description": "Country name for SongLink region picker"
},
"regionCountryCA": "Canada",
"@regionCountryCA": {
"description": "Country name for SongLink region picker"
},
"regionCountryXK": "Kosovo",
"@regionCountryXK": {
"description": "Country name for SongLink region picker"
},
"extensionVerificationBrowserTitle": "Verification browser",
"@extensionVerificationBrowserTitle": {
"description": "Settings option title for extension verification browser preference"
},
"extensionVerificationBrowserSubtitleExternal": "Open challenges in the default browser first",
"@extensionVerificationBrowserSubtitleExternal": {
"description": "Subtitle when external browser is preferred for extension verification"
},
"extensionVerificationBrowserSubtitleInApp": "Open challenges in the in-app browser first",
"@extensionVerificationBrowserSubtitleInApp": {
"description": "Subtitle when in-app browser is preferred for extension verification"
},
"extensionVerificationBrowserExternal": "External",
"@extensionVerificationBrowserExternal": {
"description": "Chip label for external browser verification mode"
},
"extensionVerificationBrowserInApp": "In-app",
"@extensionVerificationBrowserInApp": {
"description": "Chip label for in-app browser verification mode"
},
"extensionVerificationHelpTitleManual": "Open verification manually",
"@extensionVerificationHelpTitleManual": {
"description": "Dialog title when automatic browser launch for verification fails"
},
"extensionVerificationHelpTitleWaiting": "Verification still waiting",
"@extensionVerificationHelpTitleWaiting": {
"description": "Dialog title when verification is taking longer than expected"
},
"extensionVerificationHelpMessageManual": "SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.",
"@extensionVerificationHelpMessageManual": {
"description": "Dialog message when automatic browser launch for verification fails"
},
"extensionVerificationHelpMessageWaiting": "If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.",
"@extensionVerificationHelpMessageWaiting": {
"description": "Dialog message when verification may need manual browser help"
},
"extensionVerificationClose": "Close",
"@extensionVerificationClose": {
"description": "Button to dismiss the extension verification help dialog"
},
"extensionVerificationCopyLink": "Copy link",
"@extensionVerificationCopyLink": {
"description": "Button to copy the extension verification URL"
},
"extensionVerificationLinkCopied": "Verification link copied",
"@extensionVerificationLinkCopied": {
"description": "Snackbar after copying the extension verification URL"
},
"extensionVerificationOpenBrowser": "Open browser",
"@extensionVerificationOpenBrowser": {
"description": "Button to open the extension verification URL in a browser"
}
}
+33 -50
View File
@@ -142,9 +142,9 @@
}
}
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
"description": "Hint to switch back from extension search"
},
"optionsAutoFallback": "Auto Fallback",
"@optionsAutoFallback": {
@@ -156,15 +156,15 @@
},
"optionsUseExtensionProviders": "Use Extension Providers",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
"description": "Legacy setting label for extension download providers"
},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "Extension providers are required",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
"description": "Legacy status when extension providers would be disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"@optionsEmbedLyrics": {
@@ -182,27 +182,6 @@
"@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
"count": {
"type": "int"
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
@@ -390,11 +369,11 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
@@ -999,9 +978,9 @@
"@providerPriorityInfo": {
"description": "Info tip about fallback behavior"
},
"providerBuiltIn": "Built-in",
"providerBuiltIn": "Legacy",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
"description": "Legacy label retained for old generated localization compatibility"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1394,11 +1373,11 @@
"@storeClearFilters": {
"description": "Button to clear all filters"
},
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProvider": "Default Search",
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
"extensionDefaultProviderSubtitle": "Use built-in search",
"extensionDefaultProviderSubtitle": "Use the default metadata search",
"@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider"
},
@@ -1613,6 +1592,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadSelectQuality": "Select Quality",
"@downloadSelectQuality": {
"description": "Dialog title - choose audio quality"
@@ -2071,43 +2054,43 @@
},
"downloadLossy320": "Lossy 320kbps",
"@downloadLossy320": {
"description": "Quality option label for Tidal lossy 320kbps"
"description": "Quality option label for lossy 320kbps"
},
"downloadLossyFormat": "Lossy Format",
"@downloadLossyFormat": {
"description": "Setting title to pick output format for Tidal lossy downloads"
"description": "Setting title to pick output format for lossy downloads"
},
"downloadLossy320Format": "Lossy 320kbps Format",
"@downloadLossy320Format": {
"description": "Title of the Tidal lossy format picker bottom sheet"
"description": "Title of the lossy format picker bottom sheet"
},
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
"downloadLossy320FormatDesc": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
"@downloadLossy320FormatDesc": {
"description": "Description in the Tidal lossy format picker"
"description": "Description in the lossy format picker"
},
"downloadLossyMp3": "MP3 320kbps",
"@downloadLossyMp3": {
"description": "Tidal lossy format option - MP3 320kbps"
"description": "lossy format option - MP3 320kbps"
},
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps Tidal lossy option"
"description": "Subtitle for MP3 320kbps lossy option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
"description": "lossy format option - Opus 256kbps"
},
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
"@downloadLossyOpus256Subtitle": {
"description": "Subtitle for Opus 256kbps Tidal lossy option"
"description": "Subtitle for Opus 256kbps lossy option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
"description": "lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps Tidal lossy option"
"description": "Subtitle for Opus 128kbps lossy option"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
@@ -2697,7 +2680,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -3600,7 +3583,7 @@
"@lyricsProvidersDescription": {
"description": "Description on the lyrics provider priority page"
},
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
"@lyricsProvidersInfoText": {
"description": "Info tip on lyrics provider priority page"
},
@@ -3838,13 +3821,13 @@
"@downloadNetworkCompatibilityModeDisabled": {
"description": "Subtitle when network compatibility mode is off"
},
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
"@downloadSelectServiceToEnable": {
"description": "Subtitle when quality picker is disabled due to extension service"
},
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
"@downloadSelectTidalQobuz": {
"description": "Info shown when a non-built-in service is selected"
"description": "Legacy info shown when a provider does not expose quality options"
},
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
"@downloadEmbedLyricsDisabled": {
@@ -4206,7 +4189,7 @@
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
"description": "Extensions page - subtitle for default metadata search provider option",
"placeholders": {
"providerName": {
"type": "String"
+1396 -440
View File
File diff suppressed because it is too large Load Diff
+1855 -899
View File
File diff suppressed because it is too large Load Diff
+1053 -97
View File
File diff suppressed because it is too large Load Diff
+1498 -132
View File
File diff suppressed because it is too large Load Diff
+1052 -96
View File
File diff suppressed because it is too large Load Diff
+1058 -102
View File
File diff suppressed because it is too large Load Diff
+1054 -98
View File
File diff suppressed because it is too large Load Diff
+33 -50
View File
@@ -142,9 +142,9 @@
}
}
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
"description": "Hint to switch back from extension search"
},
"optionsAutoFallback": "Auto Fallback",
"@optionsAutoFallback": {
@@ -156,15 +156,15 @@
},
"optionsUseExtensionProviders": "Use Extension Providers",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
"description": "Legacy setting label for extension download providers"
},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "Extension providers are required",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
"description": "Legacy status when extension providers would be disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"@optionsEmbedLyrics": {
@@ -182,27 +182,6 @@
"@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
"count": {
"type": "int"
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
@@ -390,11 +369,11 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
@@ -999,9 +978,9 @@
"@providerPriorityInfo": {
"description": "Info tip about fallback behavior"
},
"providerBuiltIn": "Built-in",
"providerBuiltIn": "Legacy",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
"description": "Legacy label retained for old generated localization compatibility"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1394,11 +1373,11 @@
"@storeClearFilters": {
"description": "Button to clear all filters"
},
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProvider": "Default Search",
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
"extensionDefaultProviderSubtitle": "Use built-in search",
"extensionDefaultProviderSubtitle": "Use the default metadata search",
"@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider"
},
@@ -1613,6 +1592,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadSelectQuality": "Select Quality",
"@downloadSelectQuality": {
"description": "Dialog title - choose audio quality"
@@ -2071,43 +2054,43 @@
},
"downloadLossy320": "Lossy 320kbps",
"@downloadLossy320": {
"description": "Quality option label for Tidal lossy 320kbps"
"description": "Quality option label for lossy 320kbps"
},
"downloadLossyFormat": "Lossy Format",
"@downloadLossyFormat": {
"description": "Setting title to pick output format for Tidal lossy downloads"
"description": "Setting title to pick output format for lossy downloads"
},
"downloadLossy320Format": "Lossy 320kbps Format",
"@downloadLossy320Format": {
"description": "Title of the Tidal lossy format picker bottom sheet"
"description": "Title of the lossy format picker bottom sheet"
},
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
"downloadLossy320FormatDesc": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
"@downloadLossy320FormatDesc": {
"description": "Description in the Tidal lossy format picker"
"description": "Description in the lossy format picker"
},
"downloadLossyMp3": "MP3 320kbps",
"@downloadLossyMp3": {
"description": "Tidal lossy format option - MP3 320kbps"
"description": "lossy format option - MP3 320kbps"
},
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps Tidal lossy option"
"description": "Subtitle for MP3 320kbps lossy option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
"description": "lossy format option - Opus 256kbps"
},
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
"@downloadLossyOpus256Subtitle": {
"description": "Subtitle for Opus 256kbps Tidal lossy option"
"description": "Subtitle for Opus 256kbps lossy option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
"description": "lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps Tidal lossy option"
"description": "Subtitle for Opus 128kbps lossy option"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
@@ -2697,7 +2680,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -3600,7 +3583,7 @@
"@lyricsProvidersDescription": {
"description": "Description on the lyrics provider priority page"
},
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
"@lyricsProvidersInfoText": {
"description": "Info tip on lyrics provider priority page"
},
@@ -3838,13 +3821,13 @@
"@downloadNetworkCompatibilityModeDisabled": {
"description": "Subtitle when network compatibility mode is off"
},
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
"@downloadSelectServiceToEnable": {
"description": "Subtitle when quality picker is disabled due to extension service"
},
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
"@downloadSelectTidalQobuz": {
"description": "Info shown when a non-built-in service is selected"
"description": "Legacy info shown when a provider does not expose quality options"
},
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
"@downloadEmbedLyricsDisabled": {
@@ -4206,7 +4189,7 @@
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
"description": "Extensions page - subtitle for default metadata search provider option",
"placeholders": {
"providerName": {
"type": "String"
+1052 -96
View File
File diff suppressed because it is too large Load Diff
+1208 -252
View File
File diff suppressed because it is too large Load Diff
+1166 -210
View File
File diff suppressed because it is too large Load Diff
+1058 -102
View File
File diff suppressed because it is too large Load Diff
+33 -50
View File
@@ -142,9 +142,9 @@
}
}
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
"description": "Hint to switch back from extension search"
},
"optionsAutoFallback": "Auto Fallback",
"@optionsAutoFallback": {
@@ -156,15 +156,15 @@
},
"optionsUseExtensionProviders": "Use Extension Providers",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
"description": "Legacy setting label for extension download providers"
},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "Extension providers are required",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
"description": "Legacy status when extension providers would be disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"@optionsEmbedLyrics": {
@@ -182,27 +182,6 @@
"@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
"count": {
"type": "int"
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
@@ -390,11 +369,11 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
@@ -999,9 +978,9 @@
"@providerPriorityInfo": {
"description": "Info tip about fallback behavior"
},
"providerBuiltIn": "Built-in",
"providerBuiltIn": "Legacy",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
"description": "Legacy label retained for old generated localization compatibility"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1394,11 +1373,11 @@
"@storeClearFilters": {
"description": "Button to clear all filters"
},
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProvider": "Default Search",
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
"extensionDefaultProviderSubtitle": "Use built-in search",
"extensionDefaultProviderSubtitle": "Use the default metadata search",
"@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider"
},
@@ -1613,6 +1592,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadSelectQuality": "Select Quality",
"@downloadSelectQuality": {
"description": "Dialog title - choose audio quality"
@@ -2071,43 +2054,43 @@
},
"downloadLossy320": "Lossy 320kbps",
"@downloadLossy320": {
"description": "Quality option label for Tidal lossy 320kbps"
"description": "Quality option label for lossy 320kbps"
},
"downloadLossyFormat": "Lossy Format",
"@downloadLossyFormat": {
"description": "Setting title to pick output format for Tidal lossy downloads"
"description": "Setting title to pick output format for lossy downloads"
},
"downloadLossy320Format": "Lossy 320kbps Format",
"@downloadLossy320Format": {
"description": "Title of the Tidal lossy format picker bottom sheet"
"description": "Title of the lossy format picker bottom sheet"
},
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
"downloadLossy320FormatDesc": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
"@downloadLossy320FormatDesc": {
"description": "Description in the Tidal lossy format picker"
"description": "Description in the lossy format picker"
},
"downloadLossyMp3": "MP3 320kbps",
"@downloadLossyMp3": {
"description": "Tidal lossy format option - MP3 320kbps"
"description": "lossy format option - MP3 320kbps"
},
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps Tidal lossy option"
"description": "Subtitle for MP3 320kbps lossy option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
"description": "lossy format option - Opus 256kbps"
},
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
"@downloadLossyOpus256Subtitle": {
"description": "Subtitle for Opus 256kbps Tidal lossy option"
"description": "Subtitle for Opus 256kbps lossy option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
"description": "lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps Tidal lossy option"
"description": "Subtitle for Opus 128kbps lossy option"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
@@ -2697,7 +2680,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -3600,7 +3583,7 @@
"@lyricsProvidersDescription": {
"description": "Description on the lyrics provider priority page"
},
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
"@lyricsProvidersInfoText": {
"description": "Info tip on lyrics provider priority page"
},
@@ -3838,13 +3821,13 @@
"@downloadNetworkCompatibilityModeDisabled": {
"description": "Subtitle when network compatibility mode is off"
},
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
"@downloadSelectServiceToEnable": {
"description": "Subtitle when quality picker is disabled due to extension service"
},
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
"@downloadSelectTidalQobuz": {
"description": "Info shown when a non-built-in service is selected"
"description": "Legacy info shown when a provider does not expose quality options"
},
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
"@downloadEmbedLyricsDisabled": {
@@ -4206,7 +4189,7 @@
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
"description": "Extensions page - subtitle for default metadata search provider option",
"placeholders": {
"providerName": {
"type": "String"
+1061 -105
View File
File diff suppressed because it is too large Load Diff
+1052 -96
View File
File diff suppressed because it is too large Load Diff
+14 -10
View File
@@ -14,23 +14,27 @@ const int translationThreshold = 70;
/// Only these languages will be available in the app.
const List<Locale> filteredSupportedLocales = <Locale>[
Locale('en'),
Locale('ru'),
Locale('fr'),
Locale('de'),
Locale('es', 'ES'),
Locale('id'),
Locale('pt', 'PT'),
Locale('ja'),
Locale('tr'),
Locale('uk'),
Locale('ru'),
Locale('tr'),
Locale('id'),
Locale('ja'),
Locale('pt', 'PT'),
];
/// Set of locale codes for quick lookup.
const Set<String> filteredLocaleCodes = <String>{
'en',
'ru',
'fr',
'de',
'es_ES',
'id',
'pt_PT',
'ja',
'tr',
'uk',
'ru',
'tr',
'id',
'ja',
'pt_PT',
};
+34 -11
View File
@@ -15,20 +15,43 @@ import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
import 'package:spotiflac_android/utils/logger.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final runtimeProfile = await _resolveRuntimeProfile();
_configureImageCache(runtimeProfile);
final _log = AppLogger('Main');
runApp(
ProviderScope(
child: _EagerInitialization(
child: SpotiFLACApp(
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
void main() {
// Catch uncaught Dart errors so a failing async path is logged, not fatal.
// Native (Go) crashes still can't be caught here.
runZonedGuarded(
() async {
WidgetsFlutterBinding.ensureInitialized();
final previousOnError = FlutterError.onError;
FlutterError.onError = (details) {
previousOnError?.call(details);
_log.e('Uncaught Flutter error: ${details.exceptionAsString()}');
};
WidgetsBinding.instance.platformDispatcher.onError = (error, stack) {
_log.e('Uncaught platform error: $error');
return true;
};
final runtimeProfile = await _resolveRuntimeProfile();
_configureImageCache(runtimeProfile);
runApp(
ProviderScope(
child: _EagerInitialization(
child: SpotiFLACApp(
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
),
),
),
),
),
);
},
(error, stack) {
_log.e('Uncaught zone error: $error');
},
);
}
+17 -4
View File
@@ -12,7 +12,14 @@ enum DownloadStatus {
skipped,
}
enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
enum DownloadErrorType {
unknown,
notFound,
rateLimit,
network,
permission,
verificationRequired,
}
@JsonSerializable()
class DownloadItem {
@@ -22,14 +29,15 @@ class DownloadItem {
final DownloadStatus status;
final double progress;
final double speedMBps;
final int bytesReceived; // Bytes downloaded so far
final int bytesReceived;
final int bytesTotal; // Total bytes when the server provides content length
final String? filePath;
final String? error;
final DownloadErrorType? errorType;
final DateTime createdAt;
final String? qualityOverride; // Override quality for this specific download
final String? playlistName; // Playlist context for folder organization
final String? qualityOverride;
final String? playlistName;
final int? playlistPosition; // 1-based position in the source playlist
const DownloadItem({
required this.id,
@@ -46,6 +54,7 @@ class DownloadItem {
required this.createdAt,
this.qualityOverride,
this.playlistName,
this.playlistPosition,
});
DownloadItem copyWith({
@@ -63,6 +72,7 @@ class DownloadItem {
DateTime? createdAt,
String? qualityOverride,
String? playlistName,
int? playlistPosition,
}) {
return DownloadItem(
id: id ?? this.id,
@@ -79,6 +89,7 @@ class DownloadItem {
createdAt: createdAt ?? this.createdAt,
qualityOverride: qualityOverride ?? this.qualityOverride,
playlistName: playlistName ?? this.playlistName,
playlistPosition: playlistPosition ?? this.playlistPosition,
);
}
@@ -94,6 +105,8 @@ class DownloadItem {
return 'Connection failed, check your internet';
case DownloadErrorType.permission:
return 'Cannot write to folder, check storage permission';
case DownloadErrorType.verificationRequired:
return 'Verification required. Open the extension and complete the security check.';
default:
return error ?? 'An error occurred';
}
+3
View File
@@ -23,6 +23,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
createdAt: DateTime.parse(json['createdAt'] as String),
qualityOverride: json['qualityOverride'] as String?,
playlistName: json['playlistName'] as String?,
playlistPosition: (json['playlistPosition'] as num?)?.toInt(),
);
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
@@ -41,6 +42,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
'playlistName': instance.playlistName,
'playlistPosition': instance.playlistPosition,
};
const _$DownloadStatusEnumMap = {
@@ -58,4 +60,5 @@ const _$DownloadErrorTypeEnumMap = {
DownloadErrorType.rateLimit: 'rateLimit',
DownloadErrorType.network: 'network',
DownloadErrorType.permission: 'permission',
DownloadErrorType.verificationRequired: 'verificationRequired',
};
+28 -20
View File
@@ -15,14 +15,13 @@ class AppSettings {
final String storageMode; // 'app' or 'saf'
final String downloadTreeUri; // SAF persistable tree URI
final bool autoFallback;
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
final bool embedMetadata;
final String
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
final bool embedLyrics;
final bool embedReplayGain; // Calculate and embed ReplayGain tags
final bool embedReplayGain;
final bool maxQualityCover;
final bool isFirstLaunch;
final int concurrentDownloads;
final bool checkForUpdates;
final String updateChannel;
final bool hasSearchedBefore;
@@ -44,37 +43,37 @@ class AppSettings {
final String singleFilenameFormat;
final String albumFolderStructure;
final bool showExtensionStore;
final String
extensionVerificationBrowserMode; // 'external_first' or 'in_app_first'
final String locale;
final String lyricsMode;
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
autoExportFailedDownloads; // Auto export failed downloads to TXT file
final bool autoExportFailedDownloads;
final String
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
final bool
networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests
final bool
allowLocalNetwork; // Allow requests to private/local network targets (local proxy / custom DNS)
final String
songLinkRegion; // SongLink userCountry region code used for platform lookup
final bool
nativeDownloadWorkerEnabled; // Experimental Android service-owned worker
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final bool localLibraryEnabled;
final String localLibraryPath;
final String
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
final bool localLibraryShowDuplicates;
final String
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
final bool
hasCompletedTutorial; // Track if user has completed the app tutorial
final bool hasCompletedTutorial;
final List<String>
lyricsProviders; // Ordered list of enabled lyrics provider IDs
final List<String> lyricsProviders;
final bool
lyricsIncludeTranslationNetease; // Append translated lyrics (Netease)
final bool
@@ -89,9 +88,10 @@ class AppSettings {
final String
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
final bool
deduplicateDownloads; // Skip downloading tracks already present in history
final bool saveDownloadHistory; // Record completed downloads in local history
final bool deduplicateDownloads;
final bool saveDownloadHistory;
final String playerMode;
const AppSettings({
this.defaultService = '',
@@ -108,7 +108,6 @@ class AppSettings {
this.embedReplayGain = false,
this.maxQualityCover = true,
this.isFirstLaunch = true,
this.concurrentDownloads = 1,
this.checkForUpdates = true,
this.updateChannel = 'stable',
this.hasSearchedBefore = false,
@@ -130,6 +129,7 @@ class AppSettings {
this.singleFilenameFormat = '{title} - {artist}',
this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true,
this.extensionVerificationBrowserMode = 'in_app_first',
this.locale = 'system',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
@@ -137,6 +137,7 @@ class AppSettings {
this.autoExportFailedDownloads = false,
this.downloadNetworkMode = 'any',
this.networkCompatibilityMode = false,
this.allowLocalNetwork = false,
this.songLinkRegion = 'US',
this.nativeDownloadWorkerEnabled = false,
this.localLibraryEnabled = false,
@@ -154,6 +155,7 @@ class AppSettings {
this.lastSeenVersion = '',
this.deduplicateDownloads = true,
this.saveDownloadHistory = true,
this.playerMode = 'external',
});
AppSettings copyWith({
@@ -171,7 +173,6 @@ class AppSettings {
bool? embedReplayGain,
bool? maxQualityCover,
bool? isFirstLaunch,
int? concurrentDownloads,
bool? checkForUpdates,
String? updateChannel,
bool? hasSearchedBefore,
@@ -196,6 +197,7 @@ class AppSettings {
String? singleFilenameFormat,
String? albumFolderStructure,
bool? showExtensionStore,
String? extensionVerificationBrowserMode,
String? locale,
String? lyricsMode,
String? tidalHighFormat,
@@ -203,6 +205,7 @@ class AppSettings {
bool? autoExportFailedDownloads,
String? downloadNetworkMode,
bool? networkCompatibilityMode,
bool? allowLocalNetwork,
String? songLinkRegion,
bool? nativeDownloadWorkerEnabled,
bool? localLibraryEnabled,
@@ -220,6 +223,7 @@ class AppSettings {
String? lastSeenVersion,
bool? deduplicateDownloads,
bool? saveDownloadHistory,
String? playerMode,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -237,7 +241,6 @@ class AppSettings {
embedReplayGain: embedReplayGain ?? this.embedReplayGain,
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
@@ -270,6 +273,9 @@ class AppSettings {
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
extensionVerificationBrowserMode:
extensionVerificationBrowserMode ??
this.extensionVerificationBrowserMode,
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
@@ -279,6 +285,7 @@ class AppSettings {
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
networkCompatibilityMode:
networkCompatibilityMode ?? this.networkCompatibilityMode,
allowLocalNetwork: allowLocalNetwork ?? this.allowLocalNetwork,
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
nativeDownloadWorkerEnabled:
nativeDownloadWorkerEnabled ?? this.nativeDownloadWorkerEnabled,
@@ -304,6 +311,7 @@ class AppSettings {
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
playerMode: playerMode ?? this.playerMode,
);
}
+7 -2
View File
@@ -21,7 +21,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
@@ -49,6 +48,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
extensionVerificationBrowserMode:
json['extensionVerificationBrowserMode'] as String? ?? 'in_app_first',
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
@@ -57,6 +58,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['autoExportFailedDownloads'] as bool? ?? false,
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false,
allowLocalNetwork: json['allowLocalNetwork'] as bool? ?? false,
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
nativeDownloadWorkerEnabled:
json['nativeDownloadWorkerEnabled'] as bool? ?? false,
@@ -83,6 +85,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
playerMode: json['playerMode'] as String? ?? 'external',
);
Map<String, dynamic> _$AppSettingsToJson(
@@ -102,7 +105,6 @@ Map<String, dynamic> _$AppSettingsToJson(
'embedReplayGain': instance.embedReplayGain,
'maxQualityCover': instance.maxQualityCover,
'isFirstLaunch': instance.isFirstLaunch,
'concurrentDownloads': instance.concurrentDownloads,
'checkForUpdates': instance.checkForUpdates,
'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore,
@@ -125,6 +127,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'singleFilenameFormat': instance.singleFilenameFormat,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'extensionVerificationBrowserMode': instance.extensionVerificationBrowserMode,
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
@@ -132,6 +135,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
'downloadNetworkMode': instance.downloadNetworkMode,
'networkCompatibilityMode': instance.networkCompatibilityMode,
'allowLocalNetwork': instance.allowLocalNetwork,
'songLinkRegion': instance.songLinkRegion,
'nativeDownloadWorkerEnabled': instance.nativeDownloadWorkerEnabled,
'localLibraryEnabled': instance.localLibraryEnabled,
@@ -149,4 +153,5 @@ Map<String, dynamic> _$AppSettingsToJson(
'lastSeenVersion': instance.lastSeenVersion,
'deduplicateDownloads': instance.deduplicateDownloads,
'saveDownloadHistory': instance.saveDownloadHistory,
'playerMode': instance.playerMode,
};
+4
View File
@@ -13,6 +13,7 @@ class Track {
final String? albumId;
final String? coverUrl;
final String? isrc;
final String? previewUrl;
final int duration;
final int? trackNumber;
final int? discNumber;
@@ -38,6 +39,7 @@ class Track {
this.albumId,
this.coverUrl,
this.isrc,
this.previewUrl,
required this.duration,
this.trackNumber,
this.discNumber,
@@ -81,6 +83,8 @@ class Track {
audioModes != null && audioModes!.contains('DOLBY_ATMOS');
bool get hasAudioQuality => audioQuality != null && audioQuality!.isNotEmpty;
bool get hasPreview => previewUrl != null && previewUrl!.isNotEmpty;
}
@JsonSerializable()
+2
View File
@@ -16,6 +16,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
albumId: json['albumId'] as String?,
coverUrl: json['coverUrl'] as String?,
isrc: json['isrc'] as String?,
previewUrl: json['previewUrl'] as String?,
duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
@@ -46,6 +47,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'albumId': instance.albumId,
'coverUrl': instance.coverUrl,
'isrc': instance.isrc,
'previewUrl': instance.previewUrl,
'duration': instance.duration,
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
File diff suppressed because it is too large Load Diff
+285 -28
View File
@@ -14,6 +14,22 @@ final _log = AppLogger('ExtensionProvider');
const _metadataProviderPriorityKey = 'metadata_provider_priority';
const _providerPriorityKey = 'provider_priority';
const _spotifyWebExtensionId = 'spotify-web';
const _storeRegistryUrlPrefKey = 'store_registry_url';
/// Result of restoring extensions from a backup.
class ExtensionRestoreResult {
final int installed;
final int alreadyPresent;
final int failed;
final List<String> failedIds;
const ExtensionRestoreResult({
this.installed = 0,
this.alreadyPresent = 0,
this.failed = 0,
this.failedIds = const [],
});
}
bool _stringListEquals(List<String> a, List<String> b) {
if (identical(a, b)) return true;
@@ -792,12 +808,15 @@ class ExtensionInstallBatchResult {
}
class ExtensionNotifier extends Notifier<ExtensionState> {
static const _extensionHealthCacheTtl = Duration(seconds: 60);
static const _extensionHealthDefaultCacheTtl = Duration(minutes: 10);
static const _extensionHealthMinimumCacheTtl = Duration(minutes: 1);
static const _extensionHealthUnknownCacheTtl = Duration(minutes: 2);
AppLifecycleListener? _appLifecycleListener;
bool _cleanupInFlight = false;
Completer<void>? _initializationCompleter;
final Map<String, DateTime> _healthExpiresAt = {};
final Map<String, Future<ExtensionHealthStatus?>> _healthInFlight = {};
final Map<String, int> _healthRequestSerial = {};
@override
ExtensionState build() {
@@ -809,6 +828,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
_appLifecycleListener = null;
_healthExpiresAt.clear();
_healthInFlight.clear();
_healthRequestSerial.clear();
});
return const ExtensionState();
}
@@ -938,15 +958,46 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
void _scheduleExtensionHealthRefresh(List<Extension> extensions) {
void _scheduleExtensionHealthRefresh(
List<Extension> extensions, {
bool force = false,
}) {
for (final ext in extensions) {
if (!ext.enabled || !ext.hasServiceHealth) continue;
unawaited(checkExtensionHealth(ext.id));
unawaited(checkExtensionHealth(ext.id, force: force));
}
}
void refreshEnabledExtensionHealth() {
_scheduleExtensionHealthRefresh(state.extensions);
void refreshEnabledExtensionHealth({bool force = false}) {
_scheduleExtensionHealthRefresh(state.extensions, force: force);
}
Duration _extensionHealthCacheTtlFor(Extension extension) {
var ttl = _extensionHealthDefaultCacheTtl;
for (final check in extension.serviceHealth) {
final seconds = check.cacheTtlSeconds;
if (seconds == null || seconds <= 0) continue;
var checkTtl = Duration(seconds: seconds);
if (checkTtl < _extensionHealthMinimumCacheTtl) {
checkTtl = _extensionHealthMinimumCacheTtl;
}
if (checkTtl < ttl) {
ttl = checkTtl;
}
}
return ttl;
}
Duration _extensionHealthCacheTtlForStatus(
Extension extension,
String status,
) {
final ttl = _extensionHealthCacheTtlFor(extension);
if (status == 'unknown' && ttl > _extensionHealthUnknownCacheTtl) {
return _extensionHealthUnknownCacheTtl;
}
return ttl;
}
Future<ExtensionHealthStatus?> checkExtensionHealth(
@@ -974,17 +1025,22 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return inFlight;
}
final requestSerial = (_healthRequestSerial[extensionId] ?? 0) + 1;
_healthRequestSerial[extensionId] = requestSerial;
final future = () async {
try {
final result = await PlatformBridge.checkExtensionHealth(extensionId);
final status = ExtensionHealthStatus.fromJson(result);
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
_extensionHealthCacheTtl,
);
state = state.copyWith(healthStatuses: updated);
if (_healthRequestSerial[extensionId] == requestSerial) {
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
_extensionHealthCacheTtlForStatus(ext, status.status),
);
state = state.copyWith(healthStatuses: updated);
}
return status;
} catch (e) {
_log.w('Failed to check extension health for $extensionId: $e');
@@ -994,16 +1050,20 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
checkedAt: DateTime.now(),
checks: const [],
);
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
const Duration(seconds: 20),
);
state = state.copyWith(healthStatuses: updated);
if (_healthRequestSerial[extensionId] == requestSerial) {
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
_extensionHealthUnknownCacheTtl,
);
state = state.copyWith(healthStatuses: updated);
}
return status;
} finally {
_healthInFlight.remove(extensionId);
if (_healthRequestSerial[extensionId] == requestSerial) {
_healthInFlight.remove(extensionId);
}
}
}();
@@ -1283,20 +1343,20 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
.firstOrNull;
}
bool downloadProviderMatchesBuiltIn(
bool downloadProviderReplacesLegacyProvider(
String providerId,
String builtInProviderId,
String legacyProviderId,
) {
final normalizedProvider = providerId.trim().toLowerCase();
final normalizedBuiltIn = builtInProviderId.trim().toLowerCase();
if (normalizedProvider.isEmpty || normalizedBuiltIn.isEmpty) return false;
if (normalizedProvider == normalizedBuiltIn) return true;
final normalizedLegacy = legacyProviderId.trim().toLowerCase();
if (normalizedProvider.isEmpty || normalizedLegacy.isEmpty) return false;
if (normalizedProvider == normalizedLegacy) return true;
final extension = state.extensions
.where((ext) => ext.enabled && ext.hasDownloadProvider)
.where((ext) => ext.id.toLowerCase() == normalizedProvider)
.firstOrNull;
return extension?.replacesBuiltInProviders.contains(normalizedBuiltIn) ??
return extension?.replacesBuiltInProviders.contains(normalizedLegacy) ??
false;
}
@@ -1640,7 +1700,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
List<Extension> get enabledExtensions {
List<Extension> enabledExtensions() {
return state.extensions.where((ext) => ext.enabled).toList();
}
@@ -1717,11 +1777,208 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return result;
}
List<Extension> get searchProviders {
List<Extension> searchProviders() {
return state.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.toList();
}
/// Collects the keys flagged as `secret` in an extension's manifest schema
/// (top-level settings and quality-specific settings).
Set<String> _secretKeysFromManifest(Map<String, dynamic> raw) {
final keys = <String>{};
void scan(Object? settingsList) {
if (settingsList is! List) return;
for (final entry in settingsList) {
if (entry is Map && entry['secret'] == true && entry['key'] is String) {
keys.add(entry['key'] as String);
}
}
}
scan(raw['settings']);
final quality = raw['quality_options'];
if (quality is List) {
for (final option in quality) {
if (option is Map) {
scan(option['settings']);
}
}
}
return keys;
}
/// Builds the extensions section of a backup: the store registry URL plus the
/// installed extensions with their id, version, enabled flag and settings.
/// Secret-flagged settings (tokens, API keys) are only included when
/// [includeSecrets] is true.
Future<Map<String, dynamic>> exportBackup({
required bool includeSecrets,
}) async {
if (!PlatformBridge.supportsExtensionSystem) {
return {'registry_url': '', 'items': const <Map<String, dynamic>>[]};
}
String registryUrl = '';
try {
registryUrl = await PlatformBridge.getStoreRegistryUrl();
} catch (_) {}
List<Map<String, dynamic>> installed;
try {
installed = await PlatformBridge.getInstalledExtensions();
} catch (e) {
_log.w('Backup: failed to list extensions: $e');
installed = const [];
}
final items = <Map<String, dynamic>>[];
for (final raw in installed) {
final id = raw['id'] as String?;
if (id == null || id.isEmpty) continue;
final secretKeys = _secretKeysFromManifest(raw);
Map<String, dynamic> settings = {};
try {
settings = await PlatformBridge.getExtensionSettings(id);
} catch (_) {}
final filtered = <String, dynamic>{};
var omittedSecret = false;
settings.forEach((key, value) {
if (secretKeys.contains(key)) {
if (!includeSecrets) {
omittedSecret = true;
return;
}
}
filtered[key] = value;
});
items.add({
'id': id,
'version': raw['version']?.toString() ?? '',
'enabled': raw['enabled'] == true,
'settings': filtered,
if (omittedSecret) 'secrets_omitted': true,
});
}
return {'registry_url': registryUrl, 'items': items};
}
/// Restores extensions from a backup section produced by [exportBackup]:
/// re-applies the store registry URL, reinstalls each extension from the
/// store when missing, then merges settings and restores the enabled flag.
/// Missing settings (e.g. omitted secrets) are merged with the current values
/// so they are not wiped.
Future<ExtensionRestoreResult> restoreFromBackup(
Map<String, dynamic> data,
) async {
if (!PlatformBridge.supportsExtensionSystem) {
return const ExtensionRestoreResult();
}
final registryUrl = (data['registry_url'] as String?)?.trim() ?? '';
final itemsRaw = data['items'];
final items = itemsRaw is List
? itemsRaw
.whereType<Map<Object?, Object?>>()
.map((e) => Map<String, dynamic>.from(e))
.toList()
: <Map<String, dynamic>>[];
Directory? destDir;
try {
final tmp = await getTemporaryDirectory();
destDir = await Directory(
'${tmp.path}/spotiflac_restore_ext',
).create(recursive: true);
await PlatformBridge.initExtensionStore(destDir.path);
if (registryUrl.isNotEmpty) {
await PlatformBridge.setStoreRegistryUrl(registryUrl);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_storeRegistryUrlPrefKey, registryUrl);
}
} catch (e) {
_log.w('Restore: failed to prepare extension store: $e');
}
await refreshExtensions();
final installedIds = state.extensions
.map((e) => e.id.toLowerCase())
.toSet();
var installedCount = 0;
var alreadyPresent = 0;
var failed = 0;
final failedIds = <String>[];
for (final item in items) {
final id = item['id'] as String?;
if (id == null || id.isEmpty) continue;
final enabled = item['enabled'] != false;
var present = installedIds.contains(id.toLowerCase());
if (!present) {
if (destDir == null) {
failed++;
failedIds.add(id);
continue;
}
try {
final path = await PlatformBridge.downloadStoreExtension(
id,
destDir.path,
);
final ok = await installExtension(path);
if (ok) {
installedCount++;
present = true;
} else {
failed++;
failedIds.add(id);
}
} catch (e) {
_log.w('Restore: failed to install extension $id: $e');
failed++;
failedIds.add(id);
}
} else {
alreadyPresent++;
}
if (!present) continue;
final settings = item['settings'];
if (settings is Map && settings.isNotEmpty) {
try {
final current = await PlatformBridge.getExtensionSettings(id);
final merged = <String, dynamic>{
...current,
...Map<String, dynamic>.from(settings),
};
await PlatformBridge.setExtensionSettings(id, merged);
} catch (e) {
_log.w('Restore: failed to apply settings for $id: $e');
}
}
try {
await setExtensionEnabled(id, enabled);
} catch (_) {}
}
await refreshExtensions();
return ExtensionRestoreResult(
installed: installedCount,
alreadyPresent: alreadyPresent,
failed: failed,
failedIds: failedIds,
);
}
}
final extensionProvider = NotifierProvider<ExtensionNotifier, ExtensionState>(
@@ -953,6 +953,90 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
});
_invalidatePlaylistPickerSummaries();
}
/// Returns the full collections snapshot (wishlist, loved, playlists,
/// favorite artists) for a backup, ensuring data is loaded first.
Future<Map<String, dynamic>> exportCollections() async {
await _ensureLoaded();
return state.toJson();
}
/// Exports custom playlist cover images as base64, keyed by playlist id.
/// Each value contains the original file extension and the encoded bytes so a
/// restore on another device can recreate the cover files.
Future<Map<String, Map<String, String>>> exportPlaylistCovers() async {
await _ensureLoaded();
final covers = <String, Map<String, String>>{};
for (final playlist in state.playlists) {
final path = playlist.coverImagePath;
if (path == null || path.isEmpty) continue;
try {
final file = File(path);
if (!await file.exists()) continue;
final bytes = await file.readAsBytes();
if (bytes.isEmpty) continue;
covers[playlist.id] = {
'ext': p.extension(path).toLowerCase(),
'data': base64Encode(bytes),
};
} catch (_) {
// Skip unreadable cover; the rest of the backup still succeeds.
}
}
return covers;
}
/// Replaces all collections (wishlist, loved, playlists, favorite artists)
/// with the contents of a backup. [collectionsJson] uses the
/// [LibraryCollectionsState.toJson] shape; [coverImages] is the map produced
/// by [exportPlaylistCovers]. Cover images are rewritten into this device's
/// covers directory and their paths fixed up before persisting.
Future<void> restoreFromBackup(
Map<String, dynamic> collectionsJson, {
Map<String, dynamic>? coverImages,
}) async {
final normalized = Map<String, dynamic>.from(collectionsJson);
final coversDir = await _playlistCoversDir();
final playlistsRaw = normalized['playlists'];
if (playlistsRaw is List) {
final rewritten = <Map<String, dynamic>>[];
for (final entry in playlistsRaw.whereType<Map<Object?, Object?>>()) {
final playlist = Map<String, dynamic>.from(entry);
final id = playlist['id'] as String?;
String? newCoverPath;
final coverEntry = (id != null && coverImages != null)
? coverImages[id]
: null;
if (id != null && coverEntry is Map) {
final data = coverEntry['data'] as String?;
final ext = (coverEntry['ext'] as String?) ?? '.jpg';
if (data != null && data.isNotEmpty) {
try {
final destPath = p.join(coversDir.path, '$id$ext');
await File(destPath).writeAsBytes(base64Decode(data));
newCoverPath = destPath;
} catch (_) {
newCoverPath = null;
}
}
}
// Always replace the backup's device-specific path: either with the
// freshly written local cover, or drop it so a stale path is not kept.
if (newCoverPath != null) {
playlist['coverImagePath'] = newCoverPath;
} else {
playlist.remove('coverImagePath');
}
rewritten.add(playlist);
}
normalized['playlists'] = rewritten;
}
await _db.replaceAllFromBackup(normalized);
await _load();
_invalidatePlaylistPickerSummaries();
}
}
final libraryCollectionsProvider =
+153
View File
@@ -0,0 +1,153 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
final currentMediaItemProvider = StreamProvider<MediaItem?>((ref) {
return musicPlayerMediaItemEvents();
});
final playbackStateProvider = StreamProvider<PlaybackState>((ref) {
return musicPlayerPlaybackStateEvents();
});
final playQueueProvider = StreamProvider<List<MediaItem>>((ref) {
return musicPlayerQueueEvents();
});
class MusicPlayerController {
const MusicPlayerController();
MusicPlayerHandler? get _handler => musicPlayerHandler;
bool get isAvailable => _handler != null;
Future<MusicPlayerHandler?> ensureInitialized() async {
try {
return await initMusicPlayer();
} catch (_) {
return null;
}
}
Future<void> playAll(
List<PlayableMedia> items, {
int initialIndex = 0,
}) async {
final handler = await ensureInitialized();
await handler?.setQueueAndPlay(items, initialIndex: initialIndex);
}
Future<void> playSingle(PlayableMedia item) => playAll([item]);
Future<void> playHistory(
List<DownloadHistoryItem> items, {
int initialIndex = 0,
}) async {
final media = items
.where((i) => i.filePath.trim().isNotEmpty)
.map(playableFromHistory)
.toList();
if (media.isEmpty) return;
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
}
Future<void> playLocal(
List<LocalLibraryItem> items, {
int initialIndex = 0,
}) async {
final media = items
.where((i) => i.filePath.trim().isNotEmpty)
.map(playableFromLocal)
.toList();
if (media.isEmpty) return;
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
}
Future<void> play() async => _handler?.play();
Future<void> pause() async => _handler?.pause();
Future<void> stop() async => _handler?.stop();
Future<void> seek(Duration position) async => _handler?.seek(position);
Future<void> next() async => _handler?.skipToNext();
Future<void> previous() async => _handler?.skipToPrevious();
Future<void> togglePlayPause(bool isPlaying) async {
if (isPlaying) {
await pause();
} else {
await play();
}
}
Future<void> setShuffle(bool enabled) async {
await _handler?.setShuffleMode(
enabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none,
);
}
Future<void> playNext(PlayableMedia item) async =>
(await ensureInitialized())?.enqueue(item, playNext: true);
Future<void> addToQueue(PlayableMedia item) async =>
(await ensureInitialized())?.enqueue(item);
Future<void> playNextHistory(DownloadHistoryItem item) async =>
playNext(playableFromHistory(item));
Future<void> addToQueueHistory(DownloadHistoryItem item) async =>
addToQueue(playableFromHistory(item));
Future<void> playNextLocal(LocalLibraryItem item) async =>
playNext(playableFromLocal(item));
Future<void> addToQueueLocal(LocalLibraryItem item) async =>
addToQueue(playableFromLocal(item));
Future<void> jumpTo(int index) async => _handler?.skipToQueueItem(index);
void moveQueueItem(int oldIndex, int newIndex) {
_handler?.moveQueueItem(oldIndex, newIndex);
}
}
final musicPlayerControllerProvider = Provider<MusicPlayerController>(
(ref) => const MusicPlayerController(),
);
PlayableMedia playableFromHistory(DownloadHistoryItem item) {
return PlayableMedia(
id: item.id,
source: item.filePath,
title: item.trackName,
artist: item.artistName,
album: item.albumName,
artUri: (item.coverUrl != null && item.coverUrl!.trim().isNotEmpty)
? item.coverUrl
: null,
duration: (item.duration != null && item.duration! > 0)
? Duration(seconds: item.duration!)
: null,
);
}
PlayableMedia playableFromLocal(LocalLibraryItem item) {
String? art;
final cover = item.coverPath;
if (cover != null && cover.trim().isNotEmpty) {
art = cover.startsWith('http') || cover.startsWith('content://')
? cover
: Uri.file(cover).toString();
}
return PlayableMedia(
id: item.id,
source: item.filePath,
title: item.trackName,
artist: item.artistName,
album: item.albumName,
artUri: art,
duration: (item.duration != null && item.duration! > 0)
? Duration(seconds: item.duration!)
: null,
);
}
+167
View File
@@ -2,7 +2,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -16,6 +19,24 @@ class PlaybackController extends Notifier<PlaybackState> {
@override
PlaybackState build() => const PlaybackState();
Future<bool> _useInternalPlayer() async {
final mode = ref.read(settingsProvider).playerMode;
if (mode != 'internal') return false;
return await ref.read(musicPlayerControllerProvider).ensureInitialized() !=
null;
}
String? _normalizeArtUri(String cover) {
final value = cover.trim();
if (value.isEmpty) return null;
if (value.startsWith('http') ||
value.startsWith('content://') ||
value.startsWith('file://')) {
return value;
}
return Uri.file(value).toString();
}
Future<void> playLocalPath({
required String path,
required String title,
@@ -27,14 +48,143 @@ class PlaybackController extends Notifier<PlaybackState> {
if (isCueVirtualPath(path)) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
if (await _useInternalPlayer()) {
_log.d('Playing "$title" in the internal player: $path');
await ref
.read(musicPlayerControllerProvider)
.playSingle(
PlayableMedia(
id: path,
source: path,
title: title,
artist: artist,
album: album,
artUri: _normalizeArtUri(coverUrl),
duration: (track != null && track.duration > 0)
? Duration(seconds: track.duration)
: null,
),
);
return;
}
_log.d('Opening external player for "$title" by $artist: $path');
await openFile(path);
}
/// Plays a local-library album/list starting at [startItem], queuing the rest
/// so playback continues to the next track automatically. Honors player mode.
Future<void> playLocalLibraryQueue(
List<LocalLibraryItem> items, {
required LocalLibraryItem startItem,
}) async {
final playable = items
.where(
(i) => i.filePath.trim().isNotEmpty && !isCueVirtualPath(i.filePath),
)
.toList();
if (playable.isEmpty) return;
var startIndex = playable.indexWhere((i) => i.id == startItem.id);
if (startIndex < 0) startIndex = 0;
if (await _useInternalPlayer()) {
await ref
.read(musicPlayerControllerProvider)
.playLocal(playable, initialIndex: startIndex);
} else {
await openFile(playable[startIndex].filePath);
}
}
/// Plays a downloaded-history album/list starting at [startItem], queuing the
/// rest. Honors player mode.
Future<void> playHistoryQueue(
List<DownloadHistoryItem> items, {
required DownloadHistoryItem startItem,
}) async {
final playable = items
.where(
(i) => i.filePath.trim().isNotEmpty && !isCueVirtualPath(i.filePath),
)
.toList();
if (playable.isEmpty) return;
var startIndex = playable.indexWhere((i) => i.id == startItem.id);
if (startIndex < 0) startIndex = 0;
if (await _useInternalPlayer()) {
await ref
.read(musicPlayerControllerProvider)
.playHistory(playable, initialIndex: startIndex);
} else {
await openFile(playable[startIndex].filePath);
}
}
/// Plays a prebuilt media queue starting at [startIndex]. Honors player mode
/// ([externalPath] is opened externally when the built-in player is off).
Future<void> playMediaQueue(
Iterable<PlayableMedia> queue, {
required int startIndex,
required String externalPath,
}) async {
if (await _useInternalPlayer()) {
final items = queue.toList(growable: false);
if (items.isEmpty) return;
final i = startIndex.clamp(0, items.length - 1);
await ref
.read(musicPlayerControllerProvider)
.playAll(items, initialIndex: i);
} else {
await openFile(externalPath);
}
}
Future<void> playTrackList(List<Track> tracks, {int startIndex = 0}) async {
if (tracks.isEmpty) return;
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
if (await _useInternalPlayer()) {
final queue = <PlayableMedia>[];
var skippedCueVirtualTrack = false;
final resolvedPaths = await _resolveTrackPaths(orderedTracks);
for (var index = 0; index < orderedTracks.length; index++) {
final track = orderedTracks[index];
final resolvedPath = resolvedPaths[index];
if (resolvedPath == null) continue;
if (isCueVirtualPath(resolvedPath)) {
skippedCueVirtualTrack = true;
continue;
}
queue.add(
PlayableMedia(
id: resolvedPath,
source: resolvedPath,
title: track.name,
artist: track.artistName,
album: track.albumName,
artUri: _normalizeArtUri(track.coverUrl ?? ''),
duration: track.duration > 0
? Duration(seconds: track.duration)
: null,
),
);
}
if (queue.isNotEmpty) {
_log.d('Playing ${queue.length} tracks in the internal player');
await ref.read(musicPlayerControllerProvider).playAll(queue);
return;
}
if (skippedCueVirtualTrack) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
throw Exception(
'No local audio file is available to play. Download the track first.',
);
}
var skippedCueVirtualTrack = false;
for (final track in orderedTracks) {
final resolvedPath = await _resolveTrackPath(track);
@@ -98,6 +248,23 @@ class PlaybackController extends Notifier<PlaybackState> {
return null;
}
Future<List<String?>> _resolveTrackPaths(List<Track> tracks) async {
if (tracks.isEmpty) return const [];
final results = List<String?>.filled(tracks.length, null);
var next = 0;
final workerCount = tracks.length < 4 ? tracks.length : 4;
Future<void> worker() async {
while (true) {
final index = next++;
if (index >= tracks.length) return;
results[index] = await _resolveTrackPath(tracks[index]);
}
}
await Future.wait(List.generate(workerCount, (_) => worker()));
return results;
}
Future<LocalLibraryItem?> _findLocalLibraryItemForTrack(Track track) async {
final isLocalSource = (track.source ?? '').toLowerCase() == 'local';
if (isLocalSource) {

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